Compare commits
7 Commits
43b761b593
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
544c31643f | ||
|
|
57fa5aa3dd | ||
|
|
07ba77e337 | ||
|
|
9848de02cb | ||
|
|
96bd07bdb7 | ||
|
|
f972a2c020 | ||
|
|
9ec207014c |
@@ -34,6 +34,13 @@
|
||||
E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFF2CA4A8EB0019C2AF /* Page+Mock.swift */; };
|
||||
E21A573D2D8C714000E9EBE3 /* Content+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218500A2CEE02FA0090B18B /* Content+Mock.swift */; };
|
||||
E21A573E2D8C714000E9EBE3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD047D2C276F32003BFF1F /* Preview Assets.xcassets */; };
|
||||
E224E0D92E55075C0031C2B0 /* MapImageCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0D82E55075C0031C2B0 /* MapImageCreator.swift */; };
|
||||
E224E0DE2E5651DB0031C2B0 /* Sequence+Median.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */; };
|
||||
E224E0E02E5652180031C2B0 /* Locations+Sampled.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */; };
|
||||
E224E0E22E5652680031C2B0 /* WorkoutData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0E12E5652680031C2B0 /* WorkoutData.swift */; };
|
||||
E224E0E52E56528F0031C2B0 /* BinaryCodable in Frameworks */ = {isa = PBXBuildFile; productRef = E224E0E42E56528F0031C2B0 /* BinaryCodable */; };
|
||||
E224E0E72E5664AF0031C2B0 /* RoutePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */; };
|
||||
E224E0E92E5668470031C2B0 /* Time+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224E0E82E5668470031C2B0 /* Time+String.swift */; };
|
||||
E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990142D0E2B74009F8D77 /* ItemSelectionView.swift */; };
|
||||
E22990192D0E3546009F8D77 /* ItemReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990182D0E3546009F8D77 /* ItemReference.swift */; };
|
||||
E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229901D2D0E4362009F8D77 /* LocalizedItem.swift */; };
|
||||
@@ -96,7 +103,9 @@
|
||||
E25DA5952D023BD100AEF16D /* PageSettingsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5942D023BCC00AEF16D /* PageSettingsDetailView.swift */; };
|
||||
E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5982D02401A00AEF16D /* PageGenerator.swift */; };
|
||||
E25DA59B2D024A2B00AEF16D /* DateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA59A2D024A2900AEF16D /* DateItem.swift */; };
|
||||
E26C300F2E634B3A00FEB26D /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26C300E2E634B3A00FEB26D /* TimeInterval+Extensions.swift */; };
|
||||
E2720B882DF38BB700FDB543 /* Insert+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2720B872DF38BB200FDB543 /* Insert+Video.swift */; };
|
||||
E29A577E2E9E444800B19DA3 /* ToolSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29A577D2E9E444000B19DA3 /* ToolSettings.swift */; };
|
||||
E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D311F2D0320E20051B7F4 /* ContentLabels.swift */; };
|
||||
E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31212D0363FA0051B7F4 /* ContentButtons.swift */; };
|
||||
E29D31242D0366860051B7F4 /* TagList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31232D0366820051B7F4 /* TagList.swift */; };
|
||||
@@ -174,6 +183,9 @@
|
||||
E2A37D2B2CED2CC30000979F /* TagDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2A2CED2CC30000979F /* TagDetailView.swift */; };
|
||||
E2A37D2D2CED2EF10000979F /* OptionalTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */; };
|
||||
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A9CB7D2C7BCF2A005C89CC /* Page.swift */; };
|
||||
E2ADC02A2E5794AB00B4FF88 /* RouteOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */; };
|
||||
E2ADC02C2E5795F300B4FF88 /* ElevationGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */; };
|
||||
E2ADC02E2E57CC6900B4FF88 /* Double+Rounded.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ADC02D2E57CC6500B4FF88 /* Double+Rounded.swift */; };
|
||||
E2B482002D5D1136005C309D /* Vapor in Frameworks */ = {isa = PBXBuildFile; productRef = E2B481FF2D5D1136005C309D /* Vapor */; };
|
||||
E2B482032D5D1331005C309D /* WebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482022D5D132D005C309D /* WebServer.swift */; };
|
||||
E2B482052D5E7D4A005C309D /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482042D5E7D4A005C309D /* WebView.swift */; };
|
||||
@@ -198,6 +210,20 @@
|
||||
E2BF1BC82D6FC880003089F1 /* Insert+Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */; };
|
||||
E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BC92D70EDF3003089F1 /* TagPropertyView.swift */; };
|
||||
E2BF1BCC2D70EE59003089F1 /* TagPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BCB2D70EE55003089F1 /* TagPickerView.swift */; };
|
||||
E2DBA3B12E58F57B00F1E143 /* WorkoutBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3B02E58F57800F1E143 /* WorkoutBlock.swift */; };
|
||||
E2DBA3B32E58FB7500F1E143 /* StatisticsFileGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3B22E58FB6900F1E143 /* StatisticsFileGenerator.swift */; };
|
||||
E2DBA3B82E590BEE00F1E143 /* Image+Png.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3B72E590BEA00F1E143 /* Image+Png.swift */; };
|
||||
E2DBA3BA2E5CBFAE00F1E143 /* Date+Days.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3B92E5CBFA700F1E143 /* Date+Days.swift */; };
|
||||
E2DBA3BC2E5CC18500F1E143 /* FilesPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3BB2E5CC18000F1E143 /* FilesPropertyView.swift */; };
|
||||
E2DBA3C42E5E601B00F1E143 /* RouteSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3C32E5E601B00F1E143 /* RouteSeries.swift */; };
|
||||
E2DBA3C52E5E601B00F1E143 /* RouteSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3C22E5E601B00F1E143 /* RouteSample.swift */; };
|
||||
E2DBA3C62E5E601B00F1E143 /* RouteData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3C02E5E601B00F1E143 /* RouteData.swift */; };
|
||||
E2DBA3C72E5E601B00F1E143 /* RouteProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3C12E5E601B00F1E143 /* RouteProfile.swift */; };
|
||||
E2DBA3C92E5E603300F1E143 /* DataRanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3C82E5E603300F1E143 /* DataRanges.swift */; };
|
||||
E2DBA3CB2E5E603900F1E143 /* RangeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3CA2E5E603900F1E143 /* RangeInterval.swift */; };
|
||||
E2DBA3CF2E5F771F00F1E143 /* Double+Arithmetic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3CE2E5F771F00F1E143 /* Double+Arithmetic.swift */; };
|
||||
E2DBA3D12E61E5FF00F1E143 /* Point.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3D02E61E5FD00F1E143 /* Point.swift */; };
|
||||
E2DBA3D32E61F70000F1E143 /* CLLocation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DBA3D22E61F6EF00F1E143 /* CLLocation+Extensions.swift */; };
|
||||
E2DD04742C276F31003BFF1F /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DD04732C276F31003BFF1F /* MainView.swift */; };
|
||||
E2DD047A2C276F32003BFF1F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD04792C276F32003BFF1F /* Assets.xcassets */; };
|
||||
E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFA2CA4A6570019C2AF /* Content.swift */; };
|
||||
@@ -323,6 +349,12 @@
|
||||
E21850322CFAFA200090B18B /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
|
||||
E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostSettings.swift; sourceTree = "<group>"; };
|
||||
E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostFeedSettingsView.swift; sourceTree = "<group>"; };
|
||||
E224E0D82E55075C0031C2B0 /* MapImageCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapImageCreator.swift; sourceTree = "<group>"; };
|
||||
E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Median.swift"; sourceTree = "<group>"; };
|
||||
E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locations+Sampled.swift"; sourceTree = "<group>"; };
|
||||
E224E0E12E5652680031C2B0 /* WorkoutData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutData.swift; sourceTree = "<group>"; };
|
||||
E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePreviewView.swift; sourceTree = "<group>"; };
|
||||
E224E0E82E5668470031C2B0 /* Time+String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Time+String.swift"; sourceTree = "<group>"; };
|
||||
E22990142D0E2B74009F8D77 /* ItemSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectionView.swift; sourceTree = "<group>"; };
|
||||
E22990182D0E3546009F8D77 /* ItemReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemReference.swift; sourceTree = "<group>"; };
|
||||
E229901D2D0E4362009F8D77 /* LocalizedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedItem.swift; sourceTree = "<group>"; };
|
||||
@@ -382,7 +414,9 @@
|
||||
E25DA5942D023BCC00AEF16D /* PageSettingsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettingsDetailView.swift; sourceTree = "<group>"; };
|
||||
E25DA5982D02401A00AEF16D /* PageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageGenerator.swift; sourceTree = "<group>"; };
|
||||
E25DA59A2D024A2900AEF16D /* DateItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateItem.swift; sourceTree = "<group>"; };
|
||||
E26C300E2E634B3A00FEB26D /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E2720B872DF38BB200FDB543 /* Insert+Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Video.swift"; sourceTree = "<group>"; };
|
||||
E29A577D2E9E444000B19DA3 /* ToolSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolSettings.swift; sourceTree = "<group>"; };
|
||||
E29D311F2D0320E20051B7F4 /* ContentLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLabels.swift; sourceTree = "<group>"; };
|
||||
E29D31212D0363FA0051B7F4 /* ContentButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentButtons.swift; sourceTree = "<group>"; };
|
||||
E29D31232D0366820051B7F4 /* TagList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagList.swift; sourceTree = "<group>"; };
|
||||
@@ -460,6 +494,9 @@
|
||||
E2A37D2A2CED2CC30000979F /* TagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailView.swift; sourceTree = "<group>"; };
|
||||
E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalTextField.swift; sourceTree = "<group>"; };
|
||||
E2A9CB7D2C7BCF2A005C89CC /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = "<group>"; };
|
||||
E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteOverview.swift; sourceTree = "<group>"; };
|
||||
E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElevationGraph.swift; sourceTree = "<group>"; };
|
||||
E2ADC02D2E57CC6500B4FF88 /* Double+Rounded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Rounded.swift"; sourceTree = "<group>"; };
|
||||
E2B482022D5D132D005C309D /* WebServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebServer.swift; sourceTree = "<group>"; };
|
||||
E2B482042D5E7D4A005C309D /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = "<group>"; };
|
||||
E2B482082D5E7F4C005C309D /* WebsitePreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsitePreviewSheet.swift; sourceTree = "<group>"; };
|
||||
@@ -483,6 +520,20 @@
|
||||
E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Link.swift"; sourceTree = "<group>"; };
|
||||
E2BF1BC92D70EDF3003089F1 /* TagPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPropertyView.swift; sourceTree = "<group>"; };
|
||||
E2BF1BCB2D70EE55003089F1 /* TagPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPickerView.swift; sourceTree = "<group>"; };
|
||||
E2DBA3B02E58F57800F1E143 /* WorkoutBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutBlock.swift; sourceTree = "<group>"; };
|
||||
E2DBA3B22E58FB6900F1E143 /* StatisticsFileGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsFileGenerator.swift; sourceTree = "<group>"; };
|
||||
E2DBA3B72E590BEA00F1E143 /* Image+Png.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Png.swift"; sourceTree = "<group>"; };
|
||||
E2DBA3B92E5CBFA700F1E143 /* Date+Days.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Days.swift"; sourceTree = "<group>"; };
|
||||
E2DBA3BB2E5CC18000F1E143 /* FilesPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesPropertyView.swift; sourceTree = "<group>"; };
|
||||
E2DBA3C02E5E601B00F1E143 /* RouteData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteData.swift; sourceTree = "<group>"; };
|
||||
E2DBA3C12E5E601B00F1E143 /* RouteProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteProfile.swift; sourceTree = "<group>"; };
|
||||
E2DBA3C22E5E601B00F1E143 /* RouteSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSample.swift; sourceTree = "<group>"; };
|
||||
E2DBA3C32E5E601B00F1E143 /* RouteSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSeries.swift; sourceTree = "<group>"; };
|
||||
E2DBA3C82E5E603300F1E143 /* DataRanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataRanges.swift; sourceTree = "<group>"; };
|
||||
E2DBA3CA2E5E603900F1E143 /* RangeInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeInterval.swift; sourceTree = "<group>"; };
|
||||
E2DBA3CE2E5F771F00F1E143 /* Double+Arithmetic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Arithmetic.swift"; sourceTree = "<group>"; };
|
||||
E2DBA3D02E61E5FD00F1E143 /* Point.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Point.swift; sourceTree = "<group>"; };
|
||||
E2DBA3D22E61F6EF00F1E143 /* CLLocation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLLocation+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E2DD04702C276F31003BFF1F /* CHDataManagement.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CHDataManagement.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E2DD04732C276F31003BFF1F /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||
E2DD04792C276F32003BFF1F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -593,6 +644,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E224E0E52E56528F0031C2B0 /* BinaryCodable in Frameworks */,
|
||||
E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */,
|
||||
E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */,
|
||||
E2FD1D522D4644B400B48627 /* SVGView in Frameworks */,
|
||||
@@ -693,6 +745,26 @@
|
||||
path = Mock;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E224E0D72E55074E0031C2B0 /* Workouts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2DBA3D22E61F6EF00F1E143 /* CLLocation+Extensions.swift */,
|
||||
E2DBA3B92E5CBFA700F1E143 /* Date+Days.swift */,
|
||||
E2DBA3CE2E5F771F00F1E143 /* Double+Arithmetic.swift */,
|
||||
E2ADC02B2E5795F000B4FF88 /* ElevationGraph.swift */,
|
||||
E2DBA3BF2E5E601300F1E143 /* File */,
|
||||
E224E0DF2E5652120031C2B0 /* Locations+Sampled.swift */,
|
||||
E224E0D82E55075C0031C2B0 /* MapImageCreator.swift */,
|
||||
E2DBA3D02E61E5FD00F1E143 /* Point.swift */,
|
||||
E2ADC0292E5794AB00B4FF88 /* RouteOverview.swift */,
|
||||
E224E0E62E5664A70031C2B0 /* RoutePreviewView.swift */,
|
||||
E224E0DD2E5651D70031C2B0 /* Sequence+Median.swift */,
|
||||
E224E0E82E5668470031C2B0 /* Time+String.swift */,
|
||||
E224E0E12E5652680031C2B0 /* WorkoutData.swift */,
|
||||
);
|
||||
path = Workouts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E229901A2D0E3F09009F8D77 /* Item */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -711,6 +783,7 @@
|
||||
E25DA53B2D0042EA00AEF16D /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E29A577D2E9E444000B19DA3 /* ToolSettings.swift */,
|
||||
E2FD1D2D2D37180600B48627 /* GeneralSettings.swift */,
|
||||
E2FE0F392D2B3E4E002963B7 /* AudioPlayerSettings.swift */,
|
||||
E2FE0F6D2D2D3685002963B7 /* LocalizedAudioPlayerSettings.swift */,
|
||||
@@ -738,7 +811,9 @@
|
||||
E22990232D0EDBD0009F8D77 /* HeaderElement.swift */,
|
||||
E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */,
|
||||
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
|
||||
E2DBA3B42E590B1E00F1E143 /* Images */,
|
||||
E22990412D107A94009F8D77 /* ImageVersion.swift */,
|
||||
E2DBA3B22E58FB6900F1E143 /* StatisticsFileGenerator.swift */,
|
||||
E2FE0F182D2723E3002963B7 /* ImageSet.swift */,
|
||||
);
|
||||
path = Generator;
|
||||
@@ -849,6 +924,7 @@
|
||||
E29D312F2D03A2BD0051B7F4 /* DescriptionField.swift */,
|
||||
E22990292D0F5A10009F8D77 /* DetailTitle.swift */,
|
||||
E22990252D0F5822009F8D77 /* FilePropertyView.swift */,
|
||||
E2DBA3BB2E5CC18000F1E143 /* FilesPropertyView.swift */,
|
||||
E2A21C0F2CB18B390060935B /* FlowHStack.swift */,
|
||||
E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */,
|
||||
E22990392D0F7E44009F8D77 /* GenericPropertyView.swift */,
|
||||
@@ -1041,6 +1117,8 @@
|
||||
E2B85F552C4BD0AD0047CD0C /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E26C300E2E634B3A00FEB26D /* TimeInterval+Extensions.swift */,
|
||||
E2ADC02D2E57CC6500B4FF88 /* Double+Rounded.swift */,
|
||||
E2FE0F1F2D29A709002963B7 /* Array+Remove.swift */,
|
||||
E2FE0EE72D16D4A3002963B7 /* ConvertThrowing.swift */,
|
||||
E25DA5182CFF035200AEF16D /* Array+Split.swift */,
|
||||
@@ -1057,6 +1135,27 @@
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2DBA3B42E590B1E00F1E143 /* Images */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2DBA3B72E590BEA00F1E143 /* Image+Png.swift */,
|
||||
);
|
||||
path = Images;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2DBA3BF2E5E601300F1E143 /* File */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2DBA3CA2E5E603900F1E143 /* RangeInterval.swift */,
|
||||
E2DBA3C82E5E603300F1E143 /* DataRanges.swift */,
|
||||
E2DBA3C02E5E601B00F1E143 /* RouteData.swift */,
|
||||
E2DBA3C12E5E601B00F1E143 /* RouteProfile.swift */,
|
||||
E2DBA3C22E5E601B00F1E143 /* RouteSample.swift */,
|
||||
E2DBA3C32E5E601B00F1E143 /* RouteSeries.swift */,
|
||||
);
|
||||
path = File;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2DD04672C276F31003BFF1F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -1077,6 +1176,7 @@
|
||||
E2DD04722C276F31003BFF1F /* CHDataManagement */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E224E0D72E55074E0031C2B0 /* Workouts */,
|
||||
E2B482162D63AF6F005C309D /* Notifications */,
|
||||
E2B4820E2D5E9FF0005C309D /* Push */,
|
||||
E2B482012D5D1325005C309D /* Server */,
|
||||
@@ -1179,6 +1279,7 @@
|
||||
E2FE0F342D2B27E6002963B7 /* Blocks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2DBA3B02E58F57800F1E143 /* WorkoutBlock.swift */,
|
||||
E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */,
|
||||
E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */,
|
||||
E2B482212D676BEB005C309D /* PhoneScreensBlock.swift */,
|
||||
@@ -1277,6 +1378,7 @@
|
||||
E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */,
|
||||
E2FD1D512D4644B400B48627 /* SVGView */,
|
||||
E2B481FF2D5D1136005C309D /* Vapor */,
|
||||
E224E0E42E56528F0031C2B0 /* BinaryCodable */,
|
||||
);
|
||||
productName = CHDataManagement;
|
||||
productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */;
|
||||
@@ -1290,7 +1392,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1540;
|
||||
LastUpgradeCheck = 1630;
|
||||
LastUpgradeCheck = 2600;
|
||||
TargetAttributes = {
|
||||
E2DD046F2C276F31003BFF1F = {
|
||||
CreatedOnToolsVersion = 15.4;
|
||||
@@ -1316,6 +1418,7 @@
|
||||
E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
||||
E2FD1D502D4644B400B48627 /* XCRemoteSwiftPackageReference "SVGView" */,
|
||||
E2B481FE2D5D1136005C309D /* XCRemoteSwiftPackageReference "vapor" */,
|
||||
E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */,
|
||||
);
|
||||
productRefGroup = E2DD04712C276F31003BFF1F /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -1345,6 +1448,7 @@
|
||||
files = (
|
||||
E2FD1D562D46CED900B48627 /* Insert+Labels.swift in Sources */,
|
||||
E29D31242D0366860051B7F4 /* TagList.swift in Sources */,
|
||||
E2ADC02E2E57CC6900B4FF88 /* Double+Rounded.swift in Sources */,
|
||||
E22990282D0F596C009F8D77 /* IntegerPropertyView.swift in Sources */,
|
||||
E2FD1D1D2D2DE31800B48627 /* ItemType.swift in Sources */,
|
||||
E2FE0F482D2BC7D1002963B7 /* MarkdownProcessor.swift in Sources */,
|
||||
@@ -1376,6 +1480,7 @@
|
||||
E229902E2D0F7280009F8D77 /* IdPropertyView.swift in Sources */,
|
||||
E2FE0F462D2BC777002963B7 /* MarkdownImageProcessor.swift in Sources */,
|
||||
E29D31AD2D0DA5360051B7F4 /* AudioPlayerIcons.swift in Sources */,
|
||||
E2DBA3B32E58FB7500F1E143 /* StatisticsFileGenerator.swift in Sources */,
|
||||
E2FD1D5A2D477AB200B48627 /* InsertableItemsView.swift in Sources */,
|
||||
E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */,
|
||||
E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */,
|
||||
@@ -1398,6 +1503,7 @@
|
||||
E229904E2D13535C009F8D77 /* SecurityBookmark.swift in Sources */,
|
||||
E2FE0F6E2D2D3689002963B7 /* LocalizedAudioPlayerSettings.swift in Sources */,
|
||||
E2A21C082CB17B870060935B /* TagView.swift in Sources */,
|
||||
E2DBA3B82E590BEE00F1E143 /* Image+Png.swift in Sources */,
|
||||
E29D313D2D047C1B0051B7F4 /* LocalizedPageContentView.swift in Sources */,
|
||||
E2BF1BC82D6FC880003089F1 /* Insert+Link.swift in Sources */,
|
||||
E2FE0F242D2A8C21002963B7 /* TagDisplayView.swift in Sources */,
|
||||
@@ -1418,6 +1524,7 @@
|
||||
E2FE0F4F2D2BCD80002963B7 /* TagLinkCommand.swift in Sources */,
|
||||
E2FD1D302D37196C00B48627 /* GeneralSettingsDetailView.swift in Sources */,
|
||||
E22990342D0F77E9009F8D77 /* PagePropertyView.swift in Sources */,
|
||||
E224E0E92E5668470031C2B0 /* Time+String.swift in Sources */,
|
||||
E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */,
|
||||
E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */,
|
||||
E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */,
|
||||
@@ -1425,6 +1532,7 @@
|
||||
E229902C2D0F6FC6009F8D77 /* LocalizedItemId.swift in Sources */,
|
||||
E25DA5952D023BD100AEF16D /* PageSettingsDetailView.swift in Sources */,
|
||||
E21850092CEE01C30090B18B /* PagePickerView.swift in Sources */,
|
||||
E2DBA3D12E61E5FF00F1E143 /* Point.swift in Sources */,
|
||||
E29D31492D0489BB0051B7F4 /* AddFileView.swift in Sources */,
|
||||
E2B482202D670753005C309D /* WallpaperSlider.swift in Sources */,
|
||||
E29D312E2D03A0D70051B7F4 /* LocalizedPageDetailView.swift in Sources */,
|
||||
@@ -1436,6 +1544,7 @@
|
||||
E2B4821A2D63AFF6005C309D /* NotificationSender.swift in Sources */,
|
||||
E2FE0F3A2D2B3E4F002963B7 /* AudioPlayerSettings.swift in Sources */,
|
||||
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */,
|
||||
E2DBA3BC2E5CC18500F1E143 /* FilesPropertyView.swift in Sources */,
|
||||
E2FE0F092D2689F0002963B7 /* TagPageGeneratorSource.swift in Sources */,
|
||||
E22990302D0F75DE009F8D77 /* BoolPropertyView.swift in Sources */,
|
||||
E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */,
|
||||
@@ -1446,12 +1555,15 @@
|
||||
E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */,
|
||||
E22990242D0EDBD0009F8D77 /* HeaderElement.swift in Sources */,
|
||||
E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */,
|
||||
E2DBA3C92E5E603300F1E143 /* DataRanges.swift in Sources */,
|
||||
E2ADC02C2E5795F300B4FF88 /* ElevationGraph.swift in Sources */,
|
||||
E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */,
|
||||
E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */,
|
||||
E2FE0F662D2C3B3A002963B7 /* LabelsBlock.swift in Sources */,
|
||||
E20BCCAF2D53F4A500B8DBEB /* GenerationStringIssuesView.swift in Sources */,
|
||||
E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */,
|
||||
E29D31432D0488960051B7F4 /* MainContentView.swift in Sources */,
|
||||
E2DBA3CF2E5F771F00F1E143 /* Double+Arithmetic.swift in Sources */,
|
||||
E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */,
|
||||
E2B4820D2D5E811E005C309D /* TryFilesMiddleware.swift in Sources */,
|
||||
E20BCC9F2D53851400B8DBEB /* SelectableListItem.swift in Sources */,
|
||||
@@ -1462,8 +1574,10 @@
|
||||
E2FD1D462D46428100B48627 /* PageIconView.swift in Sources */,
|
||||
E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */,
|
||||
E2FE0F172D2698D5002963B7 /* LocalizedPageId.swift in Sources */,
|
||||
E224E0E72E5664AF0031C2B0 /* RoutePreviewView.swift in Sources */,
|
||||
E2FD1D2E2D37180900B48627 /* GeneralSettings.swift in Sources */,
|
||||
E2FD1D542D46577700B48627 /* HtmlProducer.swift in Sources */,
|
||||
E224E0DE2E5651DB0031C2B0 /* Sequence+Median.swift in Sources */,
|
||||
E2FE0F0D2D268A09002963B7 /* PostListPageGeneratorSource.swift in Sources */,
|
||||
E2521E002D50BB6E00C56662 /* ItemLinkResults.swift in Sources */,
|
||||
E2FE0F402D2B45D3002963B7 /* SwiftBlock.swift in Sources */,
|
||||
@@ -1471,11 +1585,13 @@
|
||||
E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */,
|
||||
E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */,
|
||||
E2F3B3A22DC769C300CFA712 /* ColoredButton.swift in Sources */,
|
||||
E2DBA3D32E61F70000F1E143 /* CLLocation+Extensions.swift in Sources */,
|
||||
E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */,
|
||||
E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */,
|
||||
E2FE0F422D2B4821002963B7 /* OtherCodeBlock.swift in Sources */,
|
||||
E21850332CFAFA2F0090B18B /* Settings.swift in Sources */,
|
||||
E29D31892D0AED1F0051B7F4 /* ModelViewer.swift in Sources */,
|
||||
E2DBA3BA2E5CBFAE00F1E143 /* Date+Days.swift in Sources */,
|
||||
E2FD1D392D3BBED300B48627 /* InsertableView.swift in Sources */,
|
||||
E29D31412D04887F0051B7F4 /* SelectedDetailView.swift in Sources */,
|
||||
E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */,
|
||||
@@ -1487,6 +1603,7 @@
|
||||
E2720B882DF38BB700FDB543 /* Insert+Video.swift in Sources */,
|
||||
E2FE0F022D266FCB002963B7 /* LocalizedNavigationSettings.swift in Sources */,
|
||||
E29D313F2D04822C0051B7F4 /* AddPostView.swift in Sources */,
|
||||
E224E0E22E5652680031C2B0 /* WorkoutData.swift in Sources */,
|
||||
E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */,
|
||||
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */,
|
||||
E2FD1D682D483CCF00B48627 /* Insert+Buttons.swift in Sources */,
|
||||
@@ -1508,6 +1625,7 @@
|
||||
E2FD1D5C2D47EEB800B48627 /* LinkedPageTagView.swift in Sources */,
|
||||
E22990382D0F7B32009F8D77 /* OptionalImagePropertyView.swift in Sources */,
|
||||
E2FE0F512D2BCDC8002963B7 /* ModelCommand.swift in Sources */,
|
||||
E224E0D92E55075C0031C2B0 /* MapImageCreator.swift in Sources */,
|
||||
E2FE0F592D2BCFE4002963B7 /* OrderedKeyBlockProcessor.swift in Sources */,
|
||||
E2FE0EEC2D1C1253002963B7 /* MultiFileSelectionView.swift in Sources */,
|
||||
E29D31B32D0DA6E80051B7F4 /* ButtonIcons.swift in Sources */,
|
||||
@@ -1515,13 +1633,16 @@
|
||||
E21A57392D8C714000E9EBE3 /* File+Mock.swift in Sources */,
|
||||
E21A573A2D8C714000E9EBE3 /* Tag+Mock.swift in Sources */,
|
||||
E21A573B2D8C714000E9EBE3 /* Post+Mock.swift in Sources */,
|
||||
E2DBA3CB2E5E603900F1E143 /* RangeInterval.swift in Sources */,
|
||||
E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */,
|
||||
E21A573D2D8C714000E9EBE3 /* Content+Mock.swift in Sources */,
|
||||
E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */,
|
||||
E224E0E02E5652180031C2B0 /* Locations+Sampled.swift in Sources */,
|
||||
E2FD1D3F2D46405000B48627 /* PostLabelsView.swift in Sources */,
|
||||
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */,
|
||||
E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */,
|
||||
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */,
|
||||
E2DBA3B12E58F57B00F1E143 /* WorkoutBlock.swift in Sources */,
|
||||
E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */,
|
||||
E2FE0F572D2BCFD4002963B7 /* BlockLineProcessor.swift in Sources */,
|
||||
E229904A2D10BB90009F8D77 /* SecurityScopeBookmark.swift in Sources */,
|
||||
@@ -1545,6 +1666,7 @@
|
||||
E2F3B3982DC54F9400CFA712 /* ChangeObservingItem.swift in Sources */,
|
||||
E21850372CFCA55F0090B18B /* LocalizedPostSettings.swift in Sources */,
|
||||
E2FD1D642D47EF4200B48627 /* DetailListItem.swift in Sources */,
|
||||
E29A577E2E9E444800B19DA3 /* ToolSettings.swift in Sources */,
|
||||
E2FE0F0B2D2689FF002963B7 /* FeedGeneratorSource.swift in Sources */,
|
||||
E2DD04742C276F31003BFF1F /* MainView.swift in Sources */,
|
||||
E20BCCAD2D53F48100B8DBEB /* IssueStatus.swift in Sources */,
|
||||
@@ -1570,6 +1692,7 @@
|
||||
E2FE0F0F2D268D4F002963B7 /* BoxCommand.swift in Sources */,
|
||||
E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */,
|
||||
E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */,
|
||||
E26C300F2E634B3A00FEB26D /* TimeInterval+Extensions.swift in Sources */,
|
||||
E20BCC9B2D535C3500B8DBEB /* ChangeObservableItem.swift in Sources */,
|
||||
E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */,
|
||||
E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */,
|
||||
@@ -1581,6 +1704,7 @@
|
||||
E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */,
|
||||
E2F3B3A42DC7DC2400CFA712 /* GenerationIssuesView.swift in Sources */,
|
||||
E25DA56D2D00EBCF00AEF16D /* NavigationBarSettingsView.swift in Sources */,
|
||||
E2ADC02A2E5794AB00B4FF88 /* RouteOverview.swift in Sources */,
|
||||
E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */,
|
||||
E2FE0F642D2C2F4D002963B7 /* ButtonBlock.swift in Sources */,
|
||||
E2FD1D5E2D47EED200B48627 /* PostImageView.swift in Sources */,
|
||||
@@ -1595,6 +1719,10 @@
|
||||
E29D31AA2D0CEE3F0051B7F4 /* AudioPlayer.swift in Sources */,
|
||||
E2FE0F4B2D2BCCAA002963B7 /* MarkdownHeadlineProcessor.swift in Sources */,
|
||||
E2FE0F532D2BCE17002963B7 /* SvgCommand.swift in Sources */,
|
||||
E2DBA3C42E5E601B00F1E143 /* RouteSeries.swift in Sources */,
|
||||
E2DBA3C52E5E601B00F1E143 /* RouteSample.swift in Sources */,
|
||||
E2DBA3C62E5E601B00F1E143 /* RouteData.swift in Sources */,
|
||||
E2DBA3C72E5E601B00F1E143 /* RouteProfile.swift in Sources */,
|
||||
E2FE0F3E2D2B4225002963B7 /* AudioSettingsDetailView.swift in Sources */,
|
||||
E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */,
|
||||
E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */,
|
||||
@@ -1687,6 +1815,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -1743,6 +1872,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
@@ -1775,7 +1905,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.3;
|
||||
MARKETING_VERSION = 1.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
@@ -1814,7 +1944,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.3;
|
||||
MARKETING_VERSION = 1.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
@@ -1849,6 +1979,14 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/christophhagen/BinaryCodable";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 3.1.0;
|
||||
};
|
||||
};
|
||||
E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/kyle-n/HighlightedTextEditor";
|
||||
@@ -1924,6 +2062,11 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
E224E0E42E56528F0031C2B0 /* BinaryCodable */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */;
|
||||
productName = BinaryCodable;
|
||||
};
|
||||
E24252002C50E0A40029FF16 /* HighlightedTextEditor */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "747e13d88856438f8013440b6d706faa50b8e06e8a370d5c6bbfaf192255f3ff",
|
||||
"originHash" : "f8a1ac1b6fd2d65b9edf0e288c06780ac6a71414f18592b869bb082fb8c7690d",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "async-http-client",
|
||||
@@ -19,6 +19,15 @@
|
||||
"version" : "1.20.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "binarycodable",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/christophhagen/BinaryCodable",
|
||||
"state" : {
|
||||
"revision" : "53f057050f3c78a1997ed0218337fd92d2eba2b5",
|
||||
"version" : "3.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "console-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1630"
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
13
CHDataManagement/Extensions/Double+Rounded.swift
Normal file
13
CHDataManagement/Extensions/Double+Rounded.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
extension Double {
|
||||
|
||||
func rounded(to interval: Double) -> Double {
|
||||
(self / interval).rounded() * interval
|
||||
}
|
||||
|
||||
func rounded(decimals: Int) -> Double {
|
||||
let factor = Double.pow(10.0, Double(decimals))
|
||||
return (self * factor).rounded() / factor
|
||||
}
|
||||
}
|
||||
25
CHDataManagement/Extensions/TimeInterval+Extensions.swift
Normal file
25
CHDataManagement/Extensions/TimeInterval+Extensions.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
extension TimeInterval {
|
||||
|
||||
func duration(locale: Locale) -> String {
|
||||
let totalMinutes = Int((self / 60).rounded(to: 5))
|
||||
let hours = totalMinutes / 60
|
||||
let minutes = totalMinutes % 60
|
||||
|
||||
let suffix = locale.identifier.hasPrefix("de") ? "Std" : "h"
|
||||
|
||||
return String(format: "%d:%02d ", hours, minutes) + suffix
|
||||
}
|
||||
|
||||
func length(roundingToNearest interval: Double) -> String {
|
||||
let rounded = Int(self.rounded(to: interval))
|
||||
return "\(rounded) m"
|
||||
}
|
||||
|
||||
func energy(roundingToNearest interval: Double) -> String {
|
||||
let rounded = Int(self.rounded(to: interval))
|
||||
return "\(rounded) kcal"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -59,7 +59,7 @@ struct RouteBlock: KeyedBlockProcessor {
|
||||
return ""
|
||||
}
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: imageId, source: "Route block")
|
||||
results.missing(file: fileId, source: "Route block")
|
||||
return ""
|
||||
}
|
||||
results.used(file: image)
|
||||
|
||||
@@ -183,10 +183,19 @@ extension VideoBlock {
|
||||
|
||||
var mimeType: String {
|
||||
switch self {
|
||||
case .h265, .h264: "video/mp4"
|
||||
case .h265: "video/mp4; codecs=\"hvc1\""
|
||||
case .h264: "video/mp4; codecs=\"avc1\""
|
||||
case .webm: "video/webm"
|
||||
}
|
||||
}
|
||||
|
||||
static func h265(codec: String) -> SourceType? {
|
||||
switch codec {
|
||||
case "hvc1": return .h265
|
||||
case "avc1": return .h264
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Source {
|
||||
|
||||
90
CHDataManagement/Generator/Blocks/WorkoutBlock.swift
Normal file
90
CHDataManagement/Generator/Blocks/WorkoutBlock.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
|
||||
struct WorkoutBlock: KeyedBlockProcessor {
|
||||
|
||||
enum Key: String {
|
||||
case chartTitle
|
||||
case components
|
||||
case mapTitle
|
||||
case caption
|
||||
case file
|
||||
}
|
||||
|
||||
static let blockId: ContentBlock = .route
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
self.language = language
|
||||
}
|
||||
|
||||
private var thumbnailWidth: Int {
|
||||
content.settings.pages.contentWidth
|
||||
}
|
||||
|
||||
private var largeImageWidth: Int {
|
||||
content.settings.pages.largeImageWidth
|
||||
}
|
||||
|
||||
func process(_ arguments: [Key : String], markdown: Substring) -> String {
|
||||
guard let fileId = arguments[.file] else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
let rawComponents = arguments[.components]
|
||||
var displayedTypes: Set<RouteStatisticType> = []
|
||||
if let rawComponents {
|
||||
rawComponents.components(separatedBy: ",").compactMap { $0.trimmed.nonEmpty }.forEach { rawType in
|
||||
if let type = RouteStatisticType(rawValue: rawType) {
|
||||
displayedTypes.insert(type)
|
||||
} else {
|
||||
results.warning("Unknown component type '\(rawType)' in route block")
|
||||
}
|
||||
}
|
||||
}
|
||||
if displayedTypes.isEmpty {
|
||||
displayedTypes = Set(RouteStatisticType.allCases)
|
||||
}
|
||||
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: fileId, source: "Route block")
|
||||
return ""
|
||||
}
|
||||
results.used(file: file)
|
||||
|
||||
// Note: Use png type, otherwise the original type would be .route
|
||||
let thumbnail = file.imageSet(type: .png, width: thumbnailWidth, height: thumbnailWidth, language: language)
|
||||
results.require(imageSet: thumbnail)
|
||||
|
||||
let largeImage = file.imageSet(type: .png, width: largeImageWidth, height: largeImageWidth, language: language)
|
||||
results.require(imageSet: largeImage)
|
||||
|
||||
results.require(header: .routeJs)
|
||||
|
||||
let id = fileId.replacingOccurrences(of: ".", with: "-")
|
||||
|
||||
let views = RouteViews(
|
||||
localization: language == .english ? .english : .german,
|
||||
chartTitle: arguments[.chartTitle],
|
||||
chartId: "chart-" + id,
|
||||
displayedTypes: displayedTypes,
|
||||
mapTitle: arguments[.mapTitle],
|
||||
mapId: "map-" + id,
|
||||
filePath: file.absoluteUrl,
|
||||
imageId: "image-" + id,
|
||||
thumbnail: thumbnail,
|
||||
largeImage: largeImage,
|
||||
caption: arguments[.caption])
|
||||
|
||||
results.require(footer: views.script)
|
||||
results.require(icons: displayedTypes.map { $0.icon })
|
||||
|
||||
return views.content
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ struct VideoCommand: CommandProcessor {
|
||||
}
|
||||
results.require(file: file)
|
||||
|
||||
guard let videoType = file.type.htmlType else {
|
||||
invalid(markdown)
|
||||
guard let videoType = file.videoType() else {
|
||||
invalid("File \(file.identifier) has an unknown video type")
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ final class ImageGenerator {
|
||||
// MARK: Image operations
|
||||
|
||||
func generate(version: ImageVersion) -> Bool {
|
||||
if version.image.type == .route {
|
||||
return generateImageForRoute(version: version)
|
||||
}
|
||||
if version.type == .avif {
|
||||
if version.image.type == .gif {
|
||||
// Skip GIFs, since they can't be converted by avifenc
|
||||
@@ -56,12 +59,15 @@ final class ImageGenerator {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
guard let data = version.image.dataContent() else {
|
||||
print("ImageGenerator: Failed to load data for image \(version.image.identifier)")
|
||||
return false
|
||||
}
|
||||
|
||||
return generate(version: version, data: data)
|
||||
}
|
||||
|
||||
private func generate(version: ImageVersion, data: Data) -> Bool {
|
||||
guard let originalImage = NSImage(data: data) else {
|
||||
print("ImageGenerator: Failed to load image \(version.image.identifier)")
|
||||
return false
|
||||
@@ -109,6 +115,34 @@ final class ImageGenerator {
|
||||
return representation
|
||||
}
|
||||
|
||||
// MARK: Routes
|
||||
|
||||
private func generateImageForRoute(version: ImageVersion) -> Bool {
|
||||
let largeImagePath = version.image.mapImagePath
|
||||
|
||||
guard storage.hasFileInOutputFolder(largeImagePath) else {
|
||||
print("ImageGenerator: No map image generated for route \(version.image.identifier)")
|
||||
return false
|
||||
}
|
||||
|
||||
let largeImageUrl = URL(fileURLWithPath: largeImagePath)
|
||||
guard let imageData = try? Data(contentsOf: largeImageUrl) else {
|
||||
print("ImageGenerator: Failed to read map image data for route \(version.image.identifier)")
|
||||
return false
|
||||
}
|
||||
|
||||
if version.type == .avif {
|
||||
let originalImagePath = version.image.outputPath(width: version.maximumWidth, height: version.maximumHeight, type: .png)
|
||||
guard createAvifUsingBash(version: version, imagePath: originalImagePath) else {
|
||||
return false
|
||||
}
|
||||
version.wasNowGenerated()
|
||||
return true
|
||||
}
|
||||
|
||||
return generate(version: version, data: imageData)
|
||||
}
|
||||
|
||||
// MARK: Avif images
|
||||
|
||||
private func create(image: NSBitmapImageRep, type: FileType, quality: CGFloat) -> Data? {
|
||||
@@ -139,19 +173,23 @@ final class ImageGenerator {
|
||||
}
|
||||
|
||||
private func createAvifUsingBash(version: ImageVersion) -> Bool {
|
||||
|
||||
let baseVersion = ImageVersion(
|
||||
image: version.image,
|
||||
type: version.image.type,
|
||||
maximumWidth: version.maximumWidth,
|
||||
maximumHeight: version.maximumHeight)
|
||||
let originalImagePath = storage.outputPath(to: baseVersion.outputPath)!.path()
|
||||
return createAvifUsingBash(version: version, imagePath: originalImagePath)
|
||||
}
|
||||
|
||||
private func createAvifUsingBash(version: ImageVersion, imagePath: String) -> Bool {
|
||||
let generatedImagePath = storage.outputPath(to: version.outputPath)!.path()
|
||||
let quality = Int(version.quality * 100)
|
||||
|
||||
// TODO: Run in security scope
|
||||
let process = Process()
|
||||
process.launchPath = "/opt/homebrew/bin/avifenc" // Adjust based on installation
|
||||
process.arguments = ["-q", "\(quality)", originalImagePath, generatedImagePath]
|
||||
process.launchPath = settings.tools.avifencPath
|
||||
process.arguments = ["-q", "\(quality)", imagePath, generatedImagePath]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
|
||||
@@ -4,6 +4,8 @@ struct ImageSet: HtmlProducer {
|
||||
|
||||
let image: FileResource
|
||||
|
||||
let type: FileType
|
||||
|
||||
let maxWidth: Int
|
||||
|
||||
let maxHeight: Int
|
||||
@@ -14,8 +16,9 @@ struct ImageSet: HtmlProducer {
|
||||
|
||||
let extraAttributes: String
|
||||
|
||||
init(image: FileResource, maxWidth: Int, maxHeight: Int, description: String?, quality: CGFloat = 0.7, extraAttributes: String? = nil) {
|
||||
init(image: FileResource, type: FileType? = nil, maxWidth: Int, maxHeight: Int, description: String?, quality: CGFloat = 0.7, extraAttributes: String? = nil) {
|
||||
self.image = image
|
||||
self.type = type ?? image.type
|
||||
self.maxWidth = maxWidth
|
||||
self.maxHeight = maxHeight
|
||||
self.description = description
|
||||
@@ -24,8 +27,6 @@ struct ImageSet: HtmlProducer {
|
||||
}
|
||||
|
||||
var jobs: [ImageVersion] {
|
||||
let type = image.type
|
||||
|
||||
let width2x = maxWidth * 2
|
||||
let height2x = maxHeight * 2
|
||||
|
||||
|
||||
28
CHDataManagement/Generator/Images/Image+Png.swift
Normal file
28
CHDataManagement/Generator/Images/Image+Png.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import AppKit
|
||||
|
||||
extension NSImage {
|
||||
|
||||
func writePng(to url: URL) -> Bool {
|
||||
// Get CGImage from NSImage
|
||||
guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Create a bitmap representation
|
||||
let bitmapRep = NSBitmapImageRep(cgImage: cgImage)
|
||||
bitmapRep.size = self.size // Preserve image size
|
||||
|
||||
// Convert to PNG data
|
||||
guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
try pngData.write(to: url)
|
||||
return true
|
||||
} catch {
|
||||
print("Error writing PNG:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,18 @@ final class PostListPageGenerator {
|
||||
images.forEach(source.results.require)
|
||||
media = .images(images)
|
||||
} else if localized.hasVideos {
|
||||
media = .video(localized.images)
|
||||
let videos: [PostVideo.Video] = localized.images.compactMap { file -> PostVideo.Video? in
|
||||
guard file.type.isVideo else {
|
||||
self.source.results.warning("File \(file.identifier) ignored due to videos present in the post")
|
||||
return nil
|
||||
}
|
||||
guard let type = file.videoType() else {
|
||||
self.source.results.warning("Video \(file.identifier) ignored due to unknown video type")
|
||||
return nil
|
||||
}
|
||||
return .init(path: file.absoluteUrl, type: type)
|
||||
}
|
||||
media = .video(videos)
|
||||
localized.images.forEach(source.results.require)
|
||||
} else {
|
||||
media = nil
|
||||
|
||||
@@ -298,6 +298,10 @@ final class GenerationResults: ObservableObject {
|
||||
func requiredOutputFile(_ path: String) {
|
||||
update { self.requiredOutputFiles.insert(path) }
|
||||
}
|
||||
|
||||
func removeUnusedFile(_ unusedFile: String) {
|
||||
update { self.unusedFilesInOutput.remove(unusedFile) }
|
||||
}
|
||||
}
|
||||
|
||||
private extension Dictionary where Value == Set<LocalizedItemId> {
|
||||
|
||||
174
CHDataManagement/Generator/StatisticsFileGenerator.swift
Normal file
174
CHDataManagement/Generator/StatisticsFileGenerator.swift
Normal file
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
struct StatisticsFileGenerationJob {
|
||||
|
||||
let mapCoordinates: [CGPoint]
|
||||
|
||||
let workout: WorkoutData
|
||||
|
||||
let outputPath: URL
|
||||
|
||||
let numberOfSamples: Int
|
||||
|
||||
/// The minimum pace to allow, in min/km
|
||||
let minimumPace: Double
|
||||
|
||||
init(mapCoordinates: [CGPoint], workout: WorkoutData, path: URL, numberOfSamples: Int, minimumPace: Double = 60) {
|
||||
self.mapCoordinates = mapCoordinates
|
||||
self.workout = workout
|
||||
self.outputPath = path
|
||||
self.numberOfSamples = numberOfSamples
|
||||
self.minimumPace = minimumPace
|
||||
}
|
||||
|
||||
var locations: [CLLocation] {
|
||||
workout.locations
|
||||
}
|
||||
|
||||
var heartRateSamples: [WorkoutData.Sample] {
|
||||
workout.heartRates
|
||||
}
|
||||
|
||||
var energy: [WorkoutData.Sample] {
|
||||
workout.energy
|
||||
}
|
||||
|
||||
var start: Date? { workout.start }
|
||||
|
||||
var end: Date? { workout.end }
|
||||
|
||||
var duration: TimeInterval {
|
||||
guard let start, let end else {
|
||||
return 0
|
||||
}
|
||||
return end.timeIntervalSince(start)
|
||||
}
|
||||
|
||||
/**
|
||||
Points representing the distance (`y`, in meters) against duration (`x`, in seconds)
|
||||
*/
|
||||
func distances() -> [Point] {
|
||||
guard var current = locations.first else { return [] }
|
||||
var distanceSoFar = 0.0
|
||||
|
||||
let start = current.timestamp
|
||||
return locations.dropFirst().map { next in
|
||||
let time = next.duration(since: start)
|
||||
distanceSoFar += next.distance(from: current)
|
||||
current = next
|
||||
return Point(x: time, y: distanceSoFar)
|
||||
}
|
||||
}
|
||||
|
||||
func run() -> Bool {
|
||||
let startLocation = locations.first!
|
||||
let start = startLocation.timestamp
|
||||
let end = locations.last!.timestamp
|
||||
let totalDuration = end.timeIntervalSince(start)
|
||||
let totalDistance = locations.totalDistance // in meter
|
||||
let minimumPace = minimumPace * 60 // Convert to s/km
|
||||
|
||||
|
||||
let rawDistances = distances() // m
|
||||
/// In meter
|
||||
let distances: [Point] = rawDistances.resample(numberOfSamples: numberOfSamples, mode: .instantaneous)
|
||||
let times = distances.map { $0.x }
|
||||
|
||||
let xValues: [Double] = locations.enumerated().map { (index, location) in
|
||||
Point(x: location.duration(since: start),
|
||||
y: mapCoordinates[index].x)
|
||||
}.resample(numberOfSamples: numberOfSamples, mode: .instantaneous)
|
||||
|
||||
let yValues: [Double] = locations.enumerated().map { (index, location) in
|
||||
Point(x: location.duration(since: start),
|
||||
y: mapCoordinates[index].y)
|
||||
}.resample(numberOfSamples: numberOfSamples, mode: .instantaneous)
|
||||
|
||||
let elevations: [Double] = locations.resample(
|
||||
numberOfSamples: numberOfSamples,
|
||||
minX: 0,
|
||||
maxX: totalDuration,
|
||||
x: { $0.duration(since: start) },
|
||||
y: { $0.altitude },
|
||||
mode: .instantaneous)
|
||||
|
||||
let speeds: [Double] = rawDistances.resample(
|
||||
numberOfSamples: numberOfSamples,
|
||||
minX: 0,
|
||||
maxX: totalDuration,
|
||||
mode: .cumulative)
|
||||
.map { $0 * 3.6 } // Convert from m/s to km/h
|
||||
.medianFiltered(windowSize: 15)
|
||||
|
||||
// Speed is km/h, pace is s/km
|
||||
let paces: [Double] = speeds.map {
|
||||
guard $0 > 0 else {
|
||||
return minimumPace
|
||||
}
|
||||
let converted = 3600 / $0 // s/km
|
||||
return min(minimumPace, converted)
|
||||
}
|
||||
|
||||
let heartRates: [Double] = heartRateSamples.resample(
|
||||
numberOfSamples: numberOfSamples,
|
||||
minX: 0,
|
||||
maxX: totalDuration,
|
||||
x: { $0.time.timeIntervalSince(start) },
|
||||
y: { $0.value },
|
||||
mode: .instantaneous)
|
||||
.map { $0 * 60 } // from hz to bpm
|
||||
.medianFiltered(windowSize: 15)
|
||||
|
||||
let energies: [Double] = energy.resample(
|
||||
numberOfSamples: numberOfSamples,
|
||||
minX: 0,
|
||||
maxX: totalDuration,
|
||||
x: { $0.time.timeIntervalSince(start) },
|
||||
y: { $0.value },
|
||||
mode: .instantaneous)
|
||||
.map { $0 * 60 } // from kcal/s to kcal/min
|
||||
.medianFiltered(windowSize: 10)
|
||||
|
||||
let series = RouteSeries(
|
||||
elevation: elevations.fittingAxisLimits(minLimit: 0),
|
||||
speed: speeds.fittingAxisLimits(minLimit: 0),
|
||||
pace: paces.fittingTimeAxisLimits(maxLimit: minimumPace),
|
||||
hr: heartRates.fittingAxisLimits(minLimit: 0),
|
||||
energy: energies.fittingAxisLimits(minLimit: 0))
|
||||
|
||||
let ranges = DataRanges(
|
||||
duration: .init(min: 0, max: totalDuration),
|
||||
time: .init(min: start.timeIntervalSince1970,
|
||||
max: end.timeIntervalSince1970),
|
||||
distance: .init(min: 0, max: totalDistance / 1000)) // km
|
||||
|
||||
let samples: [RouteSample] = (0..<numberOfSamples).map { index -> RouteSample in
|
||||
RouteSample(
|
||||
x: xValues[index],
|
||||
y: yValues[index],
|
||||
time: times[index] / totalDuration,
|
||||
distance: distances[index].y / totalDistance,
|
||||
elevation: series.elevation.scale(elevations[index]),
|
||||
speed: series.speed.scale(speeds[index]),
|
||||
pace: series.pace.scale(paces[index]),
|
||||
hr: series.hr.scale(heartRates[index]),
|
||||
energy: series.energy!.scale(energies[index]))
|
||||
}
|
||||
|
||||
let result = RouteData(
|
||||
series: series,
|
||||
ranges: ranges,
|
||||
samples: samples)
|
||||
|
||||
let data = result.encoded()
|
||||
|
||||
do {
|
||||
try data.write(to: outputPath)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to write file \(outputPath.path()): \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,41 @@ extension Content {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Routes
|
||||
|
||||
private func generateMapImage(route: FileResource) -> Bool {
|
||||
let size = route.mapImageDimensions
|
||||
let path = URL(fileURLWithPath: route.mapImagePath)
|
||||
|
||||
guard let workoutData = route.workoutData else {
|
||||
print("ImageGenerator: Failed to get workout data for route \(route.identifier)")
|
||||
return false
|
||||
}
|
||||
let mapImager = MapImageCreator(locations: workoutData.locations)
|
||||
guard let (largeImage, points) = mapImager.createMapSnapshot(size: .init(width: size.width, height: size.height)) else {
|
||||
print("ImageGenerator: Failed to generate map image for route \(route.identifier)")
|
||||
return false
|
||||
}
|
||||
guard largeImage.writePng(to: path) else {
|
||||
print("ImageGenerator: Failed to save map image for route \(route.identifier)")
|
||||
return false
|
||||
}
|
||||
|
||||
let statisticsGenerator = StatisticsFileGenerationJob(
|
||||
mapCoordinates: points,
|
||||
workout: workoutData,
|
||||
path: route.statisticsFilePath,
|
||||
numberOfSamples: 600
|
||||
)
|
||||
|
||||
guard statisticsGenerator.run() else {
|
||||
print("ImageGenerator: Failed to generate statistics for route \(route.identifier)")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Find items by id
|
||||
|
||||
func page(_ pageId: String) -> Page? {
|
||||
|
||||
@@ -33,8 +33,29 @@ extension ContentLanguage: Comparable {
|
||||
}
|
||||
}
|
||||
|
||||
extension ContentLanguage: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
rawValue
|
||||
}
|
||||
}
|
||||
|
||||
extension ContentLanguage {
|
||||
|
||||
func text(days: Int) -> String {
|
||||
switch self {
|
||||
case .english: return "\(days) day\(days == 1 ? "" : "s")"
|
||||
case .german: return "\(days) Tag\(days == 1 ? "" : "e")"
|
||||
}
|
||||
}
|
||||
|
||||
var locale: Locale {
|
||||
switch self {
|
||||
case .english: Locale(identifier: "en_US")
|
||||
case .german: Locale(identifier: "de_DE")
|
||||
}
|
||||
}
|
||||
|
||||
var next: ContentLanguage {
|
||||
switch self {
|
||||
case .english: return .german
|
||||
|
||||
@@ -125,6 +125,15 @@ final class FileResource: Item, LocalizedItem {
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(fileData: Foundation.Data) -> Bool {
|
||||
guard content.storage.save(fileData: fileData, for: identifier) else {
|
||||
return false
|
||||
}
|
||||
modifiedDate = .now
|
||||
return true
|
||||
}
|
||||
|
||||
func dataContent() -> Foundation.Data? {
|
||||
content.storage.fileData(for: identifier)
|
||||
}
|
||||
@@ -260,10 +269,11 @@ final class FileResource: Item, LocalizedItem {
|
||||
return prefix + "." + ext
|
||||
}
|
||||
|
||||
func imageSet(width: Int, height: Int, language: ContentLanguage, quality: CGFloat = 0.7, extraAttributes: String? = nil) -> ImageSet {
|
||||
func imageSet(type: FileType? = nil, width: Int, height: Int, language: ContentLanguage, quality: CGFloat = 0.7, extraAttributes: String? = nil) -> ImageSet {
|
||||
let description = self.localized(in: language)
|
||||
return .init(
|
||||
image: self,
|
||||
type: type,
|
||||
maxWidth: width,
|
||||
maxHeight: height,
|
||||
description: description,
|
||||
@@ -289,6 +299,38 @@ final class FileResource: Item, LocalizedItem {
|
||||
return content.settings.general.url + version.outputPath
|
||||
}
|
||||
|
||||
// MARK: Workout
|
||||
|
||||
#warning("Set correct map image size, ratio from settings?")
|
||||
var mapImageDimensions: (width: Int, height: Int) {
|
||||
(width: content.settings.pages.largeImageWidth, height: 0)
|
||||
}
|
||||
|
||||
var mapImagePath: String {
|
||||
let dimension = mapImageDimensions
|
||||
return outputPath(width: dimension.width, height: dimension.height, type: .png)
|
||||
}
|
||||
|
||||
var statisticsFilePath: URL {
|
||||
let path = "\(content.settings.paths.filesOutputFolderPath)/\(identifier.fileNameWithoutExtension).route"
|
||||
let fullPath = makeCleanAbsolutePath(path)
|
||||
return URL(filePath: fullPath, directoryHint: .notDirectory)
|
||||
}
|
||||
|
||||
var workoutData: WorkoutData? {
|
||||
guard type == .route else {
|
||||
return nil
|
||||
}
|
||||
guard let data = dataContent() else {
|
||||
return nil
|
||||
}
|
||||
return try? WorkoutData(data: data)
|
||||
}
|
||||
|
||||
var routeOverview: RouteOverview? {
|
||||
workoutData?.overview
|
||||
}
|
||||
|
||||
// MARK: Video thumbnail
|
||||
|
||||
func createVideoThumbnail() {
|
||||
@@ -301,6 +343,62 @@ final class FileResource: Item, LocalizedItem {
|
||||
}
|
||||
}
|
||||
|
||||
private var _videoType: String?
|
||||
|
||||
func videoType() -> String? {
|
||||
if let _videoType {
|
||||
return _videoType
|
||||
}
|
||||
_videoType = determineVideoType()
|
||||
return _videoType
|
||||
}
|
||||
|
||||
private func determineVideoType() -> String? {
|
||||
let ffmpegPath = content.settings.tools.ffprobePath
|
||||
switch type {
|
||||
case .webm:
|
||||
return "video/webm"
|
||||
case .mp4, .m4v:
|
||||
if isExternallyStored {
|
||||
return "video/mp4"
|
||||
}
|
||||
break
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return content.storage.with(file: identifier) { path in
|
||||
let process = Process()
|
||||
let arguments = "-v error -select_streams v:0 -show_entries stream=codec_tag_string -of default=noprint_wrappers=1:nokey=1 \(path.path())"
|
||||
.components(separatedBy: " ")
|
||||
process.launchPath = ffmpegPath
|
||||
process.arguments = Array(arguments)
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
process.launch()
|
||||
process.waitUntilExit()
|
||||
|
||||
let outputData = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let outputString = String(data: outputData, encoding: .utf8) ?? ""
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
print("Failed to determine video type for \(identifier)")
|
||||
print(outputString)
|
||||
return nil
|
||||
}
|
||||
let firstLine = outputString.components(separatedBy: .newlines).first!.trimmed
|
||||
guard let type = VideoBlock.SourceType.h265(codec: firstLine) else {
|
||||
print("Unknown codec type for \(identifier): \(firstLine)")
|
||||
print(outputString)
|
||||
return "video/mp4"
|
||||
}
|
||||
return type.mimeType
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Paths
|
||||
|
||||
func removeFileFromOutputFolder() {
|
||||
@@ -365,6 +463,21 @@ final class FileResource: Item, LocalizedItem {
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == ContentLabel {
|
||||
|
||||
mutating func insertOrReplace(icon: PageIcon, value: String) {
|
||||
insertOrReplace(label: .init(icon: icon, value: value))
|
||||
}
|
||||
|
||||
mutating func insertOrReplace(label: ContentLabel) {
|
||||
if let index = firstIndex(where: { $0.icon == label.icon }) {
|
||||
self[index] = label
|
||||
} else {
|
||||
append(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FileResource: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
|
||||
@@ -9,6 +9,7 @@ enum FileTypeCategory: String, CaseIterable {
|
||||
case video
|
||||
case resource
|
||||
case audio
|
||||
case route
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
@@ -19,6 +20,7 @@ enum FileTypeCategory: String, CaseIterable {
|
||||
case .video: return "Videos"
|
||||
case .resource: return "Other"
|
||||
case .audio: return "Audio"
|
||||
case .route: return "Route"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +33,7 @@ enum FileTypeCategory: String, CaseIterable {
|
||||
case .video: .video
|
||||
case .resource: .zipperPage
|
||||
case .audio: .speakerWave2CircleFill
|
||||
case .route: .map
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +141,10 @@ enum FileType: String {
|
||||
|
||||
case psd
|
||||
|
||||
// MARK: Route
|
||||
|
||||
case route
|
||||
|
||||
// MARK: Unknown
|
||||
|
||||
case unknown
|
||||
@@ -174,6 +181,8 @@ enum FileType: String {
|
||||
return .model
|
||||
case .zip, .cddx, .pdf, .key, .psd, .ttf:
|
||||
return .resource
|
||||
case .route:
|
||||
return .route
|
||||
case .noExtension, .unknown:
|
||||
return .resource
|
||||
}
|
||||
@@ -225,15 +234,11 @@ enum FileType: String {
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var htmlType: String? {
|
||||
switch self {
|
||||
case .mp4, .m4v:
|
||||
return "video/mp4"
|
||||
case .webm:
|
||||
return "video/webm"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
extension FileType: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
rawValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ final class Post: Item, DateItem, LocalizedItem {
|
||||
@Published
|
||||
var linkedPage: Page?
|
||||
|
||||
/// The workouts associated with the post
|
||||
@Published
|
||||
var associatedWorkouts: [FileResource]
|
||||
|
||||
init(content: Content,
|
||||
id: String,
|
||||
isDraft: Bool,
|
||||
@@ -47,7 +51,8 @@ final class Post: Item, DateItem, LocalizedItem {
|
||||
tags: [Tag],
|
||||
german: LocalizedPost,
|
||||
english: LocalizedPost,
|
||||
linkedPage: Page? = nil) {
|
||||
linkedPage: Page? = nil,
|
||||
associatedWorkouts: [FileResource] = []) {
|
||||
self.isDraft = isDraft
|
||||
self.createdDate = createdDate
|
||||
self.startDate = startDate
|
||||
@@ -57,6 +62,7 @@ final class Post: Item, DateItem, LocalizedItem {
|
||||
self.german = german
|
||||
self.english = english
|
||||
self.linkedPage = linkedPage
|
||||
self.associatedWorkouts = associatedWorkouts
|
||||
super.init(content: content, id: id)
|
||||
}
|
||||
|
||||
@@ -174,6 +180,16 @@ final class Post: Item, DateItem, LocalizedItem {
|
||||
english: english,
|
||||
tags: tags)
|
||||
}
|
||||
|
||||
func updateLabelsFromWorkout() {
|
||||
let workouts = associatedWorkouts.compactMap { $0.routeOverview }
|
||||
guard !workouts.isEmpty else {
|
||||
return
|
||||
}
|
||||
let overview = RouteOverview.combine(workouts)
|
||||
overview.update(labels: &german.labels, language: .german)
|
||||
overview.update(labels: &english.labels, language: .english)
|
||||
}
|
||||
}
|
||||
|
||||
extension Post: StorageItem {
|
||||
@@ -189,7 +205,8 @@ extension Post: StorageItem {
|
||||
tags: data.tags.compactMap(context.tag),
|
||||
german: .init(context: context, data: data.german),
|
||||
english: .init(context: context, data: data.english),
|
||||
linkedPage: data.linkedPageId.map(context.page))
|
||||
linkedPage: data.linkedPageId.map(context.page),
|
||||
associatedWorkouts: data.associatedWorkoutIds?.compactMap(context.file) ?? [])
|
||||
savedData = data
|
||||
}
|
||||
|
||||
@@ -202,6 +219,7 @@ extension Post: StorageItem {
|
||||
let german: LocalizedPost.Data
|
||||
let english: LocalizedPost.Data
|
||||
let linkedPageId: String?
|
||||
let associatedWorkoutIds: [String]?
|
||||
}
|
||||
|
||||
var data: Data {
|
||||
@@ -213,7 +231,8 @@ extension Post: StorageItem {
|
||||
tags: tags.map { $0.identifier },
|
||||
german: german.data,
|
||||
english: english.data,
|
||||
linkedPageId: linkedPage?.identifier)
|
||||
linkedPageId: linkedPage?.identifier,
|
||||
associatedWorkoutIds: associatedWorkouts.map { $0.identifier}.nonEmpty )
|
||||
}
|
||||
|
||||
func saveToDisk(_ data: Data) -> Bool {
|
||||
|
||||
@@ -22,6 +22,9 @@ final class Settings: ChangeObservableItem {
|
||||
@Published
|
||||
var audioPlayer: AudioPlayerSettings
|
||||
|
||||
@Published
|
||||
var tools: ToolSettings
|
||||
|
||||
weak var content: Content?
|
||||
|
||||
var cancellables: Set<AnyCancellable> = []
|
||||
@@ -31,13 +34,15 @@ final class Settings: ChangeObservableItem {
|
||||
navigation: NavigationSettings,
|
||||
posts: PostSettings,
|
||||
pages: PageSettings,
|
||||
audioPlayer: AudioPlayerSettings) {
|
||||
audioPlayer: AudioPlayerSettings,
|
||||
tools: ToolSettings) {
|
||||
self.general = general
|
||||
self.paths = paths
|
||||
self.navigation = navigation
|
||||
self.posts = posts
|
||||
self.pages = pages
|
||||
self.audioPlayer = audioPlayer
|
||||
self.tools = tools
|
||||
observeChildChanges()
|
||||
}
|
||||
|
||||
@@ -58,6 +63,7 @@ final class Settings: ChangeObservableItem {
|
||||
observe(posts)
|
||||
observe(pages)
|
||||
observe(audioPlayer)
|
||||
observe(tools)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +78,8 @@ extension Settings {
|
||||
navigation: .init(context: context, data: data.navigation),
|
||||
posts: .init(context: context, data: data.posts),
|
||||
pages: .init(context: context, data: data.pages),
|
||||
audioPlayer: .init(context: context, data: data.audioPlayer))
|
||||
audioPlayer: .init(context: context, data: data.audioPlayer),
|
||||
tools: .init(context: context, data: data.tools))
|
||||
content = context.content
|
||||
}
|
||||
|
||||
@@ -84,7 +91,8 @@ extension Settings {
|
||||
posts: posts.data,
|
||||
pages: pages.data,
|
||||
audioPlayer: audioPlayer.data,
|
||||
tagOverview: tagOverview?.data)
|
||||
tagOverview: tagOverview?.data,
|
||||
tools: tools.data)
|
||||
}
|
||||
|
||||
struct Data: Codable, Equatable {
|
||||
@@ -95,6 +103,7 @@ extension Settings {
|
||||
let pages: PageSettings.Data
|
||||
let audioPlayer: AudioPlayerSettings.Data
|
||||
let tagOverview: Tag.Data?
|
||||
let tools: ToolSettings.Data
|
||||
}
|
||||
|
||||
func saveToDisk(_ data: Data) -> Bool {
|
||||
@@ -110,7 +119,8 @@ extension Settings {
|
||||
navigation: .default,
|
||||
posts: .default,
|
||||
pages: .default,
|
||||
audioPlayer: .default)
|
||||
audioPlayer: .default,
|
||||
tools: .default)
|
||||
}
|
||||
|
||||
extension GeneralSettings {
|
||||
@@ -195,3 +205,11 @@ extension PageSettings {
|
||||
emptyPageText: "This page is empty"))
|
||||
}
|
||||
}
|
||||
|
||||
extension ToolSettings {
|
||||
|
||||
static var `default`: ToolSettings {
|
||||
.init(ffprobePath: "/opt/homebrew/bin/ffprobe",
|
||||
avifencPath: "/opt/homebrew/bin/avifenc")
|
||||
}
|
||||
}
|
||||
|
||||
39
CHDataManagement/Model/Settings/ToolSettings.swift
Normal file
39
CHDataManagement/Model/Settings/ToolSettings.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
final class ToolSettings: ObservableObject {
|
||||
|
||||
/// The items to show in the navigation bar
|
||||
@Published
|
||||
var ffprobePath: String
|
||||
|
||||
@Published
|
||||
var avifencPath: String
|
||||
|
||||
init(ffprobePath: String,
|
||||
avifencPath: String) {
|
||||
self.ffprobePath = ffprobePath
|
||||
self.avifencPath = avifencPath
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Storage
|
||||
|
||||
extension ToolSettings {
|
||||
|
||||
convenience init(context: LoadingContext, data: ToolSettings.Data) {
|
||||
self.init(
|
||||
ffprobePath: data.ffprobePath,
|
||||
avifencPath: data.avifencPath)
|
||||
}
|
||||
|
||||
struct Data: Codable, Equatable {
|
||||
let ffprobePath: String
|
||||
let avifencPath: String
|
||||
}
|
||||
|
||||
var data: Data {
|
||||
.init(
|
||||
ffprobePath: ffprobePath,
|
||||
avifencPath: avifencPath)
|
||||
}
|
||||
}
|
||||
@@ -133,3 +133,33 @@ extension Icon {
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
extension Icon {
|
||||
|
||||
struct Pencil: ContentIcon {
|
||||
|
||||
static let id = "icon-pencil"
|
||||
|
||||
static let attributes = "viewBox='0 0 16 16' fill='currentColor'"
|
||||
|
||||
static let content =
|
||||
"""
|
||||
<path d="M12.9.1a.5.5 0 0 0-.8 0l-1.6 1.7 3.7 3.7L16 3.9a.5.5 0 0 0 0-.8zm.6 6.1L9.8 2.5 3.3 9h.2a1 1 0 0 1 .5.5v.5h.5a1 1 0 0 1 .5.5v.5h.5a1 1 0 0 1 .5.5v.5h.5a1 1 0 0 1 .5.5v.2zM6 13.7V13h-.5a1 1 0 0 1-.5-.5V12h-.5a1 1 0 0 1-.5-.5V11h-.5a1 1 0 0 1-.5-.5V10h-.7l-.2.1v.2l-2 5a.5.5 0 0 0 .6.7l5-2 .2-.1z"/>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
extension Icon {
|
||||
|
||||
struct PersonPlus: ContentIcon {
|
||||
|
||||
static let id = "person-plus"
|
||||
|
||||
static let attributes = "viewBox='0 0 16 16' fill='currentColor'"
|
||||
|
||||
static let content =
|
||||
"""
|
||||
<path d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0m-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4"/><path d="M8.3 14 8 13H3q0-.5.8-1.7c.7-.6 2-1.3 4.2-1.3h.7l.8-.9L8 9c-5 0-6 3-6 4s1 1 1 1z"/>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ enum PageIcon: String, CaseIterable {
|
||||
|
||||
case bellSlash = "bell-slash"
|
||||
|
||||
case pencil
|
||||
|
||||
case personPlus = "person-plus"
|
||||
|
||||
// MARK: Statistics
|
||||
|
||||
case statisticsTime = "time"
|
||||
@@ -97,6 +101,8 @@ enum PageIcon: String, CaseIterable {
|
||||
case .leftRightArrow: Icon.LeftRightArrow.self
|
||||
case .bell: Icon.Bell.self
|
||||
case .bellSlash: Icon.BellSlash.self
|
||||
case .pencil: Icon.Pencil.self
|
||||
case .personPlus: Icon.PersonPlus.self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +117,8 @@ enum PageIcon: String, CaseIterable {
|
||||
case .video: "Video"
|
||||
case .bell: "Bell"
|
||||
case .bellSlash: "Bell With Slash"
|
||||
case .pencil: "Pencil"
|
||||
case .personPlus: "Person Plus"
|
||||
case .leftRightArrow: "LeftRightArrow"
|
||||
case .buttonExternalLink: "Button: External Link"
|
||||
case .buttonGitLink: "Button: Git Link"
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
|
||||
struct PostVideo: HtmlProducer {
|
||||
|
||||
let videos: [FileResource]
|
||||
struct Video {
|
||||
let path: String
|
||||
let type: String
|
||||
}
|
||||
|
||||
let videos: [Video]
|
||||
|
||||
func populate(_ result: inout String) {
|
||||
result += "<video autoplay loop muted playsinline>"
|
||||
result += "Video not supported."
|
||||
for video in videos {
|
||||
result += "<source src='\(video.absoluteUrl)' type='\(video.type.htmlType!)'>"
|
||||
result += "<source src='\(video.path)' type='\(video.type)'>"
|
||||
}
|
||||
result += "</video>"
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ struct FeedEntryData {
|
||||
|
||||
enum Media {
|
||||
case images([ImageSet])
|
||||
case video([FileResource])
|
||||
case video([PostVideo.Video])
|
||||
}
|
||||
|
||||
var requiresSwiper: Bool {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUICore
|
||||
import SwiftUI
|
||||
|
||||
enum SaveState {
|
||||
case storageNotInitialized
|
||||
|
||||
@@ -183,7 +183,7 @@ struct SecurityBookmark {
|
||||
with(relativePath: relativeSource) { source in
|
||||
if !exists(source) {
|
||||
if !failIfMissing { return true }
|
||||
reportError("Failed to move \(relativeSource): File does not exist")
|
||||
reportError("Failed to move \(relativeSource): File \(source) does not exist")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -414,6 +414,12 @@ final class Storage: ObservableObject {
|
||||
return contentScope.readData(at: path)
|
||||
}
|
||||
|
||||
func save(fileData: Data, for fileId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let path = filePath(file: fileId)
|
||||
return contentScope.write(fileData, to: path)
|
||||
}
|
||||
|
||||
func save(fileContent: String, for fileId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let path = filePath(file: fileId)
|
||||
@@ -426,6 +432,12 @@ final class Storage: ObservableObject {
|
||||
return await contentScope.with(relativePath: path, perform: operation)
|
||||
}
|
||||
|
||||
func with<T>(file fileId: String, perform operation: (URL) -> T?) -> T? {
|
||||
guard let contentScope else { return nil }
|
||||
let path = filePath(file: fileId)
|
||||
return contentScope.with(relativePath: path, perform: operation)
|
||||
}
|
||||
|
||||
// MARK: Video thumbnails
|
||||
|
||||
func hasVideoThumbnail(for videoId: String) -> Bool {
|
||||
|
||||
@@ -108,6 +108,9 @@ struct AddFileView: View {
|
||||
content.add(resource)
|
||||
selectedFile = resource
|
||||
}
|
||||
// We need to ensure that the metadata file is written to disk directly
|
||||
content.saveUnconditionally()
|
||||
|
||||
content.generateMissingVideoThumbnails()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ struct FileContentView: View {
|
||||
.font(.title)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
case .route:
|
||||
RoutePreviewView(file: file)
|
||||
}
|
||||
}
|
||||
}.padding()
|
||||
|
||||
@@ -181,6 +181,7 @@ struct FileDetailView: View {
|
||||
markFileAsChanged()
|
||||
if file.isExternallyStored {
|
||||
DispatchQueue.main.async {
|
||||
// This will also trigger a save
|
||||
file.isExternallyStored = false
|
||||
}
|
||||
}
|
||||
@@ -215,6 +216,7 @@ struct FileDetailView: View {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
// This will also trigger a save
|
||||
file.fileSize = nil
|
||||
file.isExternallyStored = true
|
||||
}
|
||||
@@ -225,6 +227,7 @@ struct FileDetailView: View {
|
||||
print("File '\(file.identifier)': Failed to delete file in content folder")
|
||||
return
|
||||
}
|
||||
// This will also trigger a save
|
||||
content.remove(file)
|
||||
selection.remove(file)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ struct GenerationContentView: View {
|
||||
statusWhenNonEmpty: .warning,
|
||||
items: $content.results.emptyPages) { pageId in
|
||||
HStack {
|
||||
Text("\(pageId.pageId) (\(pageId.language))")
|
||||
Text("\(pageId.pageId) (\(pageId.language.description))")
|
||||
Spacer()
|
||||
Button("Show") {
|
||||
show(page: pageId.pageId,
|
||||
@@ -168,7 +168,7 @@ struct GenerationContentView: View {
|
||||
guard content.storage.deleteInOutputFolder(unusedFile) else {
|
||||
return
|
||||
}
|
||||
content.results.unusedFilesInOutput.remove(unusedFile)
|
||||
content.results.removeUnusedFile(unusedFile)
|
||||
}
|
||||
|
||||
private func show(page pageId: String, language: ContentLanguage? = nil) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUICore
|
||||
import SwiftUI
|
||||
|
||||
enum IssueStatus {
|
||||
case nominal
|
||||
|
||||
53
CHDataManagement/Views/Generic/FilesPropertyView.swift
Normal file
53
CHDataManagement/Views/Generic/FilesPropertyView.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FilesPropertyView: View {
|
||||
|
||||
let title: LocalizedStringKey
|
||||
|
||||
let footer: LocalizedStringKey
|
||||
|
||||
@Binding
|
||||
var selectedFiles: [FileResource]
|
||||
|
||||
let allowedType: FileTypeCategory?
|
||||
|
||||
init(
|
||||
title: LocalizedStringKey,
|
||||
footer: LocalizedStringKey,
|
||||
selectedFiles: Binding<[FileResource]>,
|
||||
allowedType: FileTypeCategory? = nil
|
||||
) {
|
||||
self.title = title
|
||||
self.footer = footer
|
||||
self._selectedFiles = selectedFiles
|
||||
self.allowedType = allowedType
|
||||
}
|
||||
|
||||
private var selectedText: String {
|
||||
guard !selectedFiles.isEmpty else {
|
||||
return "No file selected"
|
||||
}
|
||||
guard selectedFiles.count == 1 else {
|
||||
return "\(selectedFiles.count) files selected"
|
||||
}
|
||||
return selectedFiles[0].identifier
|
||||
}
|
||||
|
||||
@State
|
||||
private var showFileSelectionSheet = false
|
||||
|
||||
var body: some View {
|
||||
GenericPropertyView(title: title, footer: footer) {
|
||||
HStack {
|
||||
Text(selectedText)
|
||||
Spacer()
|
||||
Button("Select") {
|
||||
showFileSelectionSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFileSelectionSheet) {
|
||||
MultiFileSelectionView(selectedFiles: $selectedFiles, allowedType: allowedType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,18 @@ struct LabelCreationView: View {
|
||||
@Environment(\.colorScheme)
|
||||
private var colorScheme
|
||||
|
||||
@Environment(\.language)
|
||||
private var language
|
||||
|
||||
@Binding
|
||||
var labels: [ContentLabel]
|
||||
|
||||
@State
|
||||
private var showWorkoutSelection = false
|
||||
|
||||
@State
|
||||
private var selectedWorkouts: [FileResource] = []
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach($labels, id: \.icon) { label in
|
||||
@@ -25,10 +34,25 @@ struct LabelCreationView: View {
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.onMove(perform: moveLabel)
|
||||
Button("Load workout") { showWorkoutSelection = true }
|
||||
.padding(.vertical, 2)
|
||||
Button("Add new label", action: addLabel)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.frame(minHeight: 250)
|
||||
.sheet(isPresented: $showWorkoutSelection) {
|
||||
MultiFileSelectionView(
|
||||
selectedFiles: $selectedWorkouts,
|
||||
allowedType: .route)
|
||||
}
|
||||
.onChange(of: selectedWorkouts) {
|
||||
let workouts = selectedWorkouts.compactMap { $0.routeOverview }
|
||||
guard !workouts.isEmpty else {
|
||||
return
|
||||
}
|
||||
let overview = RouteOverview.combine(workouts)
|
||||
overview.update(labels: &labels, language: language)
|
||||
}
|
||||
}
|
||||
|
||||
private func addLabel() {
|
||||
|
||||
@@ -28,7 +28,8 @@ struct PostContentView: View {
|
||||
TagDisplayView(tags: $post.tags)
|
||||
}
|
||||
PostLabelsView(
|
||||
post: localized,
|
||||
post: post,
|
||||
localized: localized,
|
||||
other: other)
|
||||
PostTextView(post: localized)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,15 @@ struct PostDetailView: View {
|
||||
title: "Post",
|
||||
text: "Posts capture quick updates and can link to pages")
|
||||
|
||||
if post.linkedPage == nil {
|
||||
if let page = post.linkedPage {
|
||||
ColoredButton(
|
||||
icon: .document,
|
||||
text: "Show page",
|
||||
fillColor: .blue,
|
||||
textColor: .white,
|
||||
action: { showPage(page) })
|
||||
|
||||
} else {
|
||||
ColoredButton(
|
||||
icon: .documentBadgePlus,
|
||||
text: "Create page",
|
||||
@@ -81,6 +89,12 @@ struct PostDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
FilesPropertyView(
|
||||
title: "Associated workout",
|
||||
footer: "The workout file to display with this post",
|
||||
selectedFiles: $post.associatedWorkouts,
|
||||
allowedType: .route)
|
||||
|
||||
LocalizedPostDetailView(
|
||||
post: post.localized(in: language),
|
||||
transferImage: transferImage)
|
||||
@@ -100,6 +114,11 @@ struct PostDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func showPage(_ page: Page) {
|
||||
selection.page = page
|
||||
selection.tab = .pages
|
||||
}
|
||||
|
||||
private func deletePost() {
|
||||
guard content.storage.delete(post: post.identifier) else {
|
||||
print("Post '\(post.identifier)': Failed to delete file in content folder")
|
||||
|
||||
@@ -3,7 +3,10 @@ import SwiftUI
|
||||
struct PostLabelsView: View {
|
||||
|
||||
@ObservedObject
|
||||
var post: LocalizedPost
|
||||
var post: Post
|
||||
|
||||
@ObservedObject
|
||||
var localized: LocalizedPost
|
||||
|
||||
@ObservedObject
|
||||
var other: LocalizedPost
|
||||
@@ -17,11 +20,11 @@ struct PostLabelsView: View {
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 5) {
|
||||
if post.labels.isEmpty {
|
||||
if localized.labels.isEmpty {
|
||||
Text("Labels")
|
||||
.font(.headline)
|
||||
}
|
||||
ForEach(post.labels) { label in
|
||||
ForEach(localized.labels) { label in
|
||||
HStack {
|
||||
PageIconView(icon: label.icon)
|
||||
.frame(maxWidth: 16, maxHeight: 16)
|
||||
@@ -45,16 +48,16 @@ struct PostLabelsView: View {
|
||||
}.buttonStyle(.plain)
|
||||
if !other.labels.isEmpty {
|
||||
Button("Transfer") {
|
||||
post.labels = other.labels.map {
|
||||
localized.labels = other.labels.map {
|
||||
// Copy instead of reference
|
||||
ContentLabel(icon: $0.icon, value: $0.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !post.labels.isEmpty {
|
||||
if !localized.labels.isEmpty {
|
||||
Button("Copy") {
|
||||
var command = "```labels"
|
||||
for label in post.labels {
|
||||
for label in localized.labels {
|
||||
command += "\n\(label.icon.rawValue): \(label.value)"
|
||||
}
|
||||
command += "\n```"
|
||||
@@ -63,23 +66,28 @@ struct PostLabelsView: View {
|
||||
pasteboard.setString(command, forType: .string)
|
||||
}
|
||||
}
|
||||
if !post.associatedWorkouts.isEmpty {
|
||||
Button("From workout") {
|
||||
post.updateLabelsFromWorkout()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.sheet(isPresented: $showLabelEditor) {
|
||||
LabelModificationView(labels: $post.labels)
|
||||
LabelModificationView(labels: $localized.labels)
|
||||
}
|
||||
}
|
||||
|
||||
func addLabel() {
|
||||
post.labels.append(.init(icon: .clockFill, value: "Value"))
|
||||
localized.labels.append(.init(icon: .clockFill, value: "Value"))
|
||||
}
|
||||
|
||||
func remove(_ label: ContentLabel) {
|
||||
guard let index = post.labels.firstIndex(of: label) else {
|
||||
guard let index = localized.labels.firstIndex(of: label) else {
|
||||
return
|
||||
}
|
||||
post.labels.remove(at: index)
|
||||
localized.labels.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
104
CHDataManagement/Workouts/CLLocation+Extensions.swift
Normal file
104
CHDataManagement/Workouts/CLLocation+Extensions.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import CoreLocation
|
||||
|
||||
extension CLLocation {
|
||||
|
||||
func duration(since other: CLLocation) -> TimeInterval {
|
||||
duration(since: other.timestamp)
|
||||
}
|
||||
|
||||
func duration(since other: Date) -> TimeInterval {
|
||||
timestamp.timeIntervalSince(other)
|
||||
}
|
||||
|
||||
func speed(from other: CLLocation) -> Double {
|
||||
distance(from: other) / duration(since: other)
|
||||
}
|
||||
|
||||
/// Combined uncertainty sphere radius (meters) from horizontal+vertical accuracy
|
||||
var uncertaintyRadius3D: CLLocationDistance {
|
||||
let h = max(0, horizontalAccuracy)
|
||||
let v = max(0, verticalAccuracy)
|
||||
return sqrt(h * h + v * v)
|
||||
}
|
||||
|
||||
func verticalDistance(from other: CLLocation) -> CLLocationDistance {
|
||||
abs(self.altitude - other.altitude)
|
||||
}
|
||||
|
||||
func minimumDistance(to other: CLLocation) -> (distance: CLLocationDistance, point: CLLocation) {
|
||||
let horizontalDistance = distance(from: other)
|
||||
let horizontalMovement = Swift.max(0, horizontalDistance - Swift.max(0, other.horizontalAccuracy))
|
||||
|
||||
let latitude: CLLocationDegrees
|
||||
let longitude: CLLocationDegrees
|
||||
if horizontalDistance == 0 || horizontalMovement == 0 {
|
||||
latitude = coordinate.latitude
|
||||
longitude = coordinate.longitude
|
||||
} else {
|
||||
let horizontalRatio = horizontalMovement / horizontalDistance
|
||||
latitude = coordinate.latitude.move(horizontalRatio, to: other.coordinate.latitude)
|
||||
longitude = coordinate.longitude.move(horizontalRatio, to: other.coordinate.longitude)
|
||||
}
|
||||
|
||||
let verticalDistance = verticalDistance(from: other)
|
||||
let verticalMovement = Swift.max(0, verticalDistance - Swift.max(0, other.verticalAccuracy))
|
||||
|
||||
let altitude: CLLocationDistance
|
||||
if verticalDistance == 0 || verticalMovement == 0 {
|
||||
altitude = self.altitude
|
||||
} else {
|
||||
let verticalRatio = verticalMovement / verticalDistance
|
||||
altitude = self.altitude.move(verticalRatio, to: other.altitude)
|
||||
}
|
||||
|
||||
let movement = sqrt(horizontalMovement * horizontalMovement + verticalMovement * verticalMovement)
|
||||
let point = CLLocation(
|
||||
coordinate: .init(latitude: latitude, longitude: longitude),
|
||||
altitude: altitude,
|
||||
horizontalAccuracy: 0,
|
||||
verticalAccuracy: 0,
|
||||
timestamp: other.timestamp
|
||||
)
|
||||
return (movement, point)
|
||||
}
|
||||
|
||||
func interpolate(_ time: Date, to other: CLLocation) -> CLLocation {
|
||||
if self.timestamp > other.timestamp {
|
||||
return other.interpolate(time, to: self)
|
||||
}
|
||||
let totalDuration = other.timestamp.timeIntervalSince(self.timestamp)
|
||||
if totalDuration == 0 { return move(0.5, to: other) }
|
||||
let ratio = time.timeIntervalSince(self.timestamp) / totalDuration
|
||||
return move(ratio, to: other)
|
||||
}
|
||||
|
||||
func move(_ ratio: Double, to other: CLLocation) -> CLLocation {
|
||||
if ratio <= 0 { return self }
|
||||
if ratio >= 1 { return other }
|
||||
|
||||
let time = timestamp.addingTimeInterval(other.timestamp.timeIntervalSince(timestamp) * ratio)
|
||||
|
||||
return CLLocation(
|
||||
coordinate: .init(
|
||||
latitude: coordinate.latitude.move(ratio, to: other.coordinate.latitude),
|
||||
longitude: coordinate.longitude.move(ratio, to: other.coordinate.longitude)),
|
||||
altitude: altitude.move(ratio, to: other.altitude),
|
||||
horizontalAccuracy: move(from: horizontalAccuracy, to: other.horizontalAccuracy, by: ratio),
|
||||
verticalAccuracy: move(from: verticalAccuracy, to: other.verticalAccuracy, by: ratio),
|
||||
course: move(from: course, to: other.course, by: ratio),
|
||||
courseAccuracy: move(from: courseAccuracy, to: other.courseAccuracy, by: ratio),
|
||||
speed: move(from: speed, to: other.speed, by: ratio),
|
||||
speedAccuracy: move(from: speedAccuracy, to: other.speedAccuracy, by: ratio),
|
||||
timestamp: time)
|
||||
}
|
||||
|
||||
private func move(from source: Double, to other: Double, by ratio: Double) -> Double {
|
||||
if source == -1 {
|
||||
return other
|
||||
}
|
||||
if other == -1 {
|
||||
return source
|
||||
}
|
||||
return source.move(ratio, to: other)
|
||||
}
|
||||
}
|
||||
15
CHDataManagement/Workouts/Date+Days.swift
Normal file
15
CHDataManagement/Workouts/Date+Days.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
|
||||
func inclusiveDays(to other: Date, calendar: Calendar = .current) -> Int {
|
||||
let startDay = calendar.startOfDay(for: self)
|
||||
let endDay = calendar.startOfDay(for: other)
|
||||
|
||||
guard let days = calendar.dateComponents([.day], from: startDay, to: endDay).day else {
|
||||
return 0
|
||||
}
|
||||
|
||||
return abs(days) + 1
|
||||
}
|
||||
}
|
||||
115
CHDataManagement/Workouts/Double+Arithmetic.swift
Normal file
115
CHDataManagement/Workouts/Double+Arithmetic.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
|
||||
extension Double {
|
||||
|
||||
/**
|
||||
Move to a different value by the given ratio of their distance.
|
||||
*/
|
||||
func move(_ ratio: Double, to other: Double) -> Double {
|
||||
self + (other - self) * ratio
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == Double {
|
||||
|
||||
func sum() -> Double {
|
||||
reduce(0, +)
|
||||
}
|
||||
|
||||
func average() -> Double {
|
||||
sum() / Double(count)
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == Double, Index == Int {
|
||||
|
||||
func floatingMeanFiltered(windowSize: Int) -> [Double] {
|
||||
guard windowSize > 1 else {
|
||||
return self.map { $0 }
|
||||
}
|
||||
guard count >= windowSize else {
|
||||
return .init(repeating: average(), count: count)
|
||||
}
|
||||
|
||||
let firstHalf = windowSize / 2
|
||||
let secondHalf = windowSize - firstHalf
|
||||
var minSpeed: Double = 0
|
||||
var maxSpeed: Double = 0
|
||||
let averageScale = 1.0 / Double(windowSize)
|
||||
|
||||
// First calculate the filtered speeds in the normal unit
|
||||
var currentAverage = self[0..<windowSize].average()
|
||||
var result: [Double] = .init(repeating: currentAverage, count: firstHalf + 1)
|
||||
for index in firstHalf..<count-firstHalf-1 { // Index in self
|
||||
let removed = self[index-firstHalf]
|
||||
let added = self[index+secondHalf]
|
||||
currentAverage += (added - removed) * averageScale
|
||||
result.append(currentAverage)
|
||||
if currentAverage < minSpeed { minSpeed = currentAverage }
|
||||
else if currentAverage > maxSpeed { maxSpeed = currentAverage }
|
||||
}
|
||||
result.append(contentsOf: [Double](repeating: currentAverage, count: secondHalf))
|
||||
return result
|
||||
}
|
||||
|
||||
func fittingAxisLimits(desiredNumSteps: Int = 5, minLimit: Double? = nil, maxLimit: Double? = nil) -> RouteProfile {
|
||||
let (dataMin, dataMax) = minMax(minLimit: minLimit, maxLimit: maxLimit)
|
||||
|
||||
let dataRange = dataMax - dataMin
|
||||
let roughStep = dataRange / Double(desiredNumSteps)
|
||||
|
||||
let exponent = floor(log10(roughStep))
|
||||
let base = pow(10.0, exponent)
|
||||
|
||||
let step: Double
|
||||
if roughStep <= base {
|
||||
step = base
|
||||
} else if roughStep <= 2 * base {
|
||||
step = 2 * base
|
||||
} else if roughStep <= 5 * base {
|
||||
step = 5 * base
|
||||
} else {
|
||||
step = 10 * base
|
||||
}
|
||||
|
||||
let graphMin = floor(dataMin / step) * step
|
||||
let graphMax = ceil(dataMax / step) * step
|
||||
let numTicks = Int((graphMax - graphMin) / step)
|
||||
return .init(min: graphMin, max: graphMax, ticks: numTicks)
|
||||
}
|
||||
|
||||
func minMax(minLimit: Double? = nil, maxLimit: Double? = nil) -> (min: Double, max: Double) {
|
||||
var dataMin = self.min() ?? 0
|
||||
var dataMax = self.max() ?? 1
|
||||
if let minLimit {
|
||||
dataMin = Swift.max(dataMin, minLimit)
|
||||
}
|
||||
if let maxLimit {
|
||||
dataMax = Swift.min(dataMax, maxLimit)
|
||||
}
|
||||
return (dataMin, dataMax)
|
||||
}
|
||||
|
||||
func fittingTimeAxisLimits(minTicks: Int = 3, maxTicks: Int = 7, minLimit: Double? = nil, maxLimit: Double? = nil) -> RouteProfile {
|
||||
let (dataMin, dataMax) = minMax(minLimit: minLimit, maxLimit: maxLimit)
|
||||
|
||||
let dataRange = dataMax - dataMin
|
||||
let niceSteps: [Double] = [15, 30, 60, 120, 300, 600, 900, 1800, 3600] // in seconds
|
||||
|
||||
// Find the step size that gives a nice number of ticks
|
||||
var chosenStep = niceSteps.last! // fallback to largest if none fit
|
||||
for step in niceSteps {
|
||||
let numTicks = dataRange / step
|
||||
if Double(minTicks) <= numTicks && numTicks <= Double(maxTicks) {
|
||||
chosenStep = step
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let graphMin = floor(dataMin / chosenStep) * chosenStep
|
||||
let graphMax = ceil(dataMax / chosenStep) * chosenStep
|
||||
let numTicks = Int((graphMax-graphMin) / chosenStep)
|
||||
return .init(min: graphMin, max: graphMax, ticks: numTicks)
|
||||
}
|
||||
|
||||
}
|
||||
49
CHDataManagement/Workouts/ElevationGraph.swift
Normal file
49
CHDataManagement/Workouts/ElevationGraph.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct ElevationSample: Identifiable {
|
||||
|
||||
let timestamp: Date
|
||||
|
||||
let altitude: Double
|
||||
|
||||
var id: Date {
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
|
||||
struct ElevationGraph: View {
|
||||
|
||||
let samples: [ElevationSample]
|
||||
|
||||
var body: some View {
|
||||
Chart {
|
||||
// Active segments as area + line
|
||||
ForEach(samples) { sample in
|
||||
LineMark(
|
||||
x: .value("Time", sample.timestamp),
|
||||
y: .value("Altitude", sample.altitude)
|
||||
)
|
||||
.foregroundStyle(by: .value("Series", "Altitude"))
|
||||
//.interpolationMethod(.catmullRom)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Time", sample.timestamp),
|
||||
y: .value("Altitude", sample.altitude)
|
||||
)
|
||||
//.interpolationMethod(.catmullRom)
|
||||
.foregroundStyle(LinearGradient(
|
||||
gradient: Gradient(colors: [.blue.opacity(0.8), .blue.opacity(0.2)]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading)
|
||||
}
|
||||
.chartXScale(domain: samples.first!.timestamp...samples.last!.timestamp)
|
||||
.frame(width: 700, height: 220)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
13
CHDataManagement/Workouts/File/DataRanges.swift
Normal file
13
CHDataManagement/Workouts/File/DataRanges.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
struct DataRanges {
|
||||
|
||||
let duration: RangeInterval
|
||||
|
||||
let time: RangeInterval
|
||||
|
||||
let distance: RangeInterval
|
||||
}
|
||||
|
||||
extension DataRanges: Codable {
|
||||
|
||||
}
|
||||
11
CHDataManagement/Workouts/File/RangeInterval.swift
Normal file
11
CHDataManagement/Workouts/File/RangeInterval.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
struct RangeInterval {
|
||||
|
||||
let min: Double
|
||||
|
||||
let max: Double
|
||||
}
|
||||
|
||||
extension RangeInterval: Codable {
|
||||
|
||||
}
|
||||
26
CHDataManagement/Workouts/File/RouteData.swift
Normal file
26
CHDataManagement/Workouts/File/RouteData.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
All data needed to create statistic displays
|
||||
*/
|
||||
struct RouteData {
|
||||
|
||||
let series: RouteSeries
|
||||
|
||||
let ranges: DataRanges
|
||||
|
||||
let samples: [RouteSample]
|
||||
|
||||
}
|
||||
|
||||
extension RouteData: Codable {
|
||||
|
||||
func encoded(prettyPrinted: Bool = false) -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .sortedKeys
|
||||
if prettyPrinted {
|
||||
encoder.outputFormatting.insert(.prettyPrinted)
|
||||
}
|
||||
return try! encoder.encode(self)
|
||||
}
|
||||
}
|
||||
32
CHDataManagement/Workouts/File/RouteProfile.swift
Normal file
32
CHDataManagement/Workouts/File/RouteProfile.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
struct RouteProfile: Codable {
|
||||
|
||||
let min: Double
|
||||
|
||||
let max: Double
|
||||
|
||||
let ticks: Int
|
||||
|
||||
let span: Double
|
||||
|
||||
let scale: Double
|
||||
|
||||
init(min: Double, max: Double, ticks: Int) {
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.ticks = ticks
|
||||
self.span = max - min
|
||||
self.scale = 1 / span
|
||||
}
|
||||
|
||||
func scale(_ value: Double) -> Double {
|
||||
if value < min {
|
||||
return 0
|
||||
}
|
||||
if value > max {
|
||||
return 1
|
||||
}
|
||||
return (value - min) / span
|
||||
}
|
||||
}
|
||||
|
||||
95
CHDataManagement/Workouts/File/RouteSample.swift
Normal file
95
CHDataManagement/Workouts/File/RouteSample.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
|
||||
struct RouteSample {
|
||||
|
||||
/// The x-coordinate in the map image (left to right) in the range [0,1]
|
||||
var x: Double
|
||||
|
||||
/// The y-coordinate in the map image (top to bottom) in the range [0,1]
|
||||
var y: Double
|
||||
|
||||
/// The timestamp of the sample in the range [0,1]
|
||||
var time: Double
|
||||
|
||||
/// The distance of the sample in the range [0,1]
|
||||
var distance: Double
|
||||
|
||||
/// The elevation of the sample in the range [0,1]
|
||||
var elevation: Double
|
||||
|
||||
/// The speed of the sample in the range [0,1]
|
||||
var speed: Double
|
||||
|
||||
/// The pace of the sample in the range [0,1]
|
||||
var pace: Double
|
||||
|
||||
/// The heart rate of the sample in the range [0,1]
|
||||
var hr: Double
|
||||
|
||||
/// The active energy rate of the sample in the range [0,1]
|
||||
var energy: Double?
|
||||
}
|
||||
|
||||
extension RouteSample: Codable {
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(x.rounded(decimals: 4), forKey: .x)
|
||||
try container.encode(y.rounded(decimals: 4), forKey: .y)
|
||||
try container.encode(time.rounded(decimals: 4), forKey: .time)
|
||||
try container.encode(distance.rounded(decimals: 4), forKey: .distance)
|
||||
try container.encode(elevation.rounded(decimals: 4), forKey: .elevation)
|
||||
try container.encode(speed.rounded(decimals: 4), forKey: .speed)
|
||||
try container.encode(pace.rounded(decimals: 4), forKey: .pace)
|
||||
try container.encode(hr.rounded(decimals: 4), forKey: .hr)
|
||||
try container.encodeIfPresent(energy?.rounded(decimals: 4), forKey: .energy)
|
||||
}
|
||||
}
|
||||
|
||||
extension RouteSample: Identifiable {
|
||||
|
||||
var id: Double { x }
|
||||
}
|
||||
|
||||
extension RouteSample {
|
||||
|
||||
static var zero: RouteSample {
|
||||
.init(x: 0, y: 0, time: 0, distance: 0, elevation: 0, speed: 0, pace: 0, hr: 0, energy: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == RouteSample {
|
||||
|
||||
var averageSample: RouteSample {
|
||||
guard var average = first else {
|
||||
return .zero
|
||||
}
|
||||
var energySamples = average.energy == nil ? 0 : 1
|
||||
for sample in dropFirst() {
|
||||
average.x += sample.x
|
||||
average.y += sample.y
|
||||
average.time += sample.time
|
||||
average.distance += sample.distance
|
||||
average.elevation += sample.elevation
|
||||
average.speed += sample.speed
|
||||
average.pace += sample.pace
|
||||
average.hr += sample.hr
|
||||
if let energy = sample.energy {
|
||||
average.energy = (average.energy ?? 0) + energy
|
||||
energySamples += 1
|
||||
}
|
||||
}
|
||||
let scale = 1 / Double(count)
|
||||
average.x *= scale
|
||||
average.y *= scale
|
||||
average.time *= scale
|
||||
average.distance *= scale
|
||||
average.elevation *= scale
|
||||
average.speed *= scale
|
||||
average.pace *= scale
|
||||
average.hr *= scale
|
||||
if let energy = average.energy, energySamples > 0 {
|
||||
average.energy = energy / Double(energySamples)
|
||||
}
|
||||
return average
|
||||
}
|
||||
}
|
||||
18
CHDataManagement/Workouts/File/RouteSeries.swift
Normal file
18
CHDataManagement/Workouts/File/RouteSeries.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct RouteSeries {
|
||||
|
||||
let elevation: RouteProfile
|
||||
|
||||
let speed: RouteProfile
|
||||
|
||||
let pace: RouteProfile
|
||||
|
||||
let hr: RouteProfile
|
||||
|
||||
let energy: RouteProfile?
|
||||
}
|
||||
|
||||
extension RouteSeries: Codable {
|
||||
|
||||
}
|
||||
168
CHDataManagement/Workouts/Locations+Sampled.swift
Normal file
168
CHDataManagement/Workouts/Locations+Sampled.swift
Normal file
@@ -0,0 +1,168 @@
|
||||
import CoreLocation
|
||||
|
||||
extension Array where Element == CLLocation {
|
||||
|
||||
var totalDistance: CLLocationDistance {
|
||||
zip(self, dropFirst())
|
||||
.map { $0.distance(from: $1) }
|
||||
.reduce(0, +)
|
||||
}
|
||||
|
||||
var duration: TimeInterval {
|
||||
guard let start = first, let end = last else { return 0 }
|
||||
return end.timestamp.timeIntervalSince(start.timestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
Sample the locations using a given time interval.
|
||||
*/
|
||||
func samplePeriodically(at interval: TimeInterval) -> [CLLocation] {
|
||||
guard interval > 0 else { return [] }
|
||||
guard let start = first, let end = last else { return self }
|
||||
let totalTime = end.timestamp.timeIntervalSince(start.timestamp)
|
||||
let numberOfSamples = Int((totalTime / interval).rounded(.up))
|
||||
return periodicSamples(interval: interval, numberOfSamples: numberOfSamples)
|
||||
}
|
||||
|
||||
/**
|
||||
Sample the locations at a fixed period determined by the number of desired sampels
|
||||
*/
|
||||
func samplePeriodically(numberOfSamples: Int) -> [CLLocation] {
|
||||
guard numberOfSamples > 0 else { return [] }
|
||||
guard let start = first, let end = last else { return self }
|
||||
let totalTime = end.timestamp.timeIntervalSince(start.timestamp)
|
||||
let timeInterval = totalTime / TimeInterval(count - 1)
|
||||
return periodicSamples(interval: timeInterval, numberOfSamples: numberOfSamples)
|
||||
}
|
||||
|
||||
private func periodicSamples(interval: TimeInterval, numberOfSamples: Int) -> [CLLocation] {
|
||||
guard let start = first else { return [] }
|
||||
var currentIndex = 0
|
||||
var currentTime = start.timestamp
|
||||
|
||||
var samples = [start]
|
||||
for _ in 1..<numberOfSamples {
|
||||
currentTime = currentTime.addingTimeInterval(interval)
|
||||
while true {
|
||||
let nextIndex = currentIndex + 1
|
||||
if nextIndex >= count { break }
|
||||
let nextTime = self[nextIndex].timestamp
|
||||
if nextTime > currentTime { break }
|
||||
currentIndex += 1
|
||||
}
|
||||
if currentIndex + 1 == count {
|
||||
samples.append(self[currentIndex])
|
||||
} else {
|
||||
let before = self[currentIndex]
|
||||
let after = self[currentIndex + 1]
|
||||
let interpolated = before.interpolate(currentTime, to: after)
|
||||
samples.append(interpolated)
|
||||
}
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
/// Computes path length by moving along center-to-center lines, intersecting uncertainty spheres
|
||||
func minimumTraveledDistance3D() -> CLLocationDistance {
|
||||
guard count > 1 else { return 0 }
|
||||
|
||||
// Remove the uncertainty radius of the first location
|
||||
var current = self.first!
|
||||
var totalDistance: CLLocationDistance = -current.uncertaintyRadius3D
|
||||
for next in self[1...] {
|
||||
let (movement, point) = current.minimumDistance(to: next)
|
||||
current = point
|
||||
totalDistance += movement
|
||||
}
|
||||
return totalDistance
|
||||
}
|
||||
|
||||
/// Calculates the minimum possible ascended altitude (meters),
|
||||
/// considering vertical accuracy as an uncertainty interval.
|
||||
func minimumAscendedAltitude() -> CLLocationDistance {
|
||||
guard let first = self.first else { return 0 }
|
||||
|
||||
// Start with the highest possible value of the first point
|
||||
var currentAltitude = first.altitude + first.verticalAccuracy
|
||||
var ascended: CLLocationDistance = 0
|
||||
|
||||
for next in self.dropFirst() {
|
||||
let newMin = next.altitude - next.verticalAccuracy
|
||||
let newMax = next.altitude + next.verticalAccuracy
|
||||
|
||||
if newMin > currentAltitude {
|
||||
// Lower bound must be adjusted
|
||||
ascended += newMin - currentAltitude
|
||||
currentAltitude = newMin
|
||||
} else if newMax < currentAltitude {
|
||||
// Upper bound must be adjusted
|
||||
currentAltitude = newMax
|
||||
}
|
||||
}
|
||||
return ascended
|
||||
}
|
||||
|
||||
/// Calculates the minimum possible ascended altitude (meters),
|
||||
/// considering a given vertical accuracy threshold
|
||||
func minimumAscendedAltitude(threshold: CLLocationDistance) -> CLLocationDistance {
|
||||
guard let first = self.first else { return 0 }
|
||||
|
||||
// Start with the highest possible value of the first point
|
||||
var currentAltitude = first.altitude + threshold
|
||||
var ascended: CLLocationDistance = 0
|
||||
|
||||
for next in self.dropFirst() {
|
||||
let newMin = next.altitude - threshold
|
||||
let newMax = next.altitude + threshold
|
||||
|
||||
if newMin > currentAltitude {
|
||||
// Lower bound must be adjusted
|
||||
ascended += newMin - currentAltitude
|
||||
currentAltitude = newMin
|
||||
} else if newMax < currentAltitude {
|
||||
// Upper bound must be adjusted
|
||||
currentAltitude = newMax
|
||||
}
|
||||
}
|
||||
return ascended
|
||||
}
|
||||
|
||||
func interpolateAltitudes(
|
||||
from startDate: Date,
|
||||
to endDate: Date
|
||||
) -> [CLLocation] {
|
||||
|
||||
// Ensure valid range
|
||||
guard startDate < endDate else { return self }
|
||||
|
||||
// Find first and last locations in the window
|
||||
guard
|
||||
let startLocation = first(where: { $0.timestamp >= startDate }),
|
||||
let endLocation = last(where: { $0.timestamp <= endDate })
|
||||
else {
|
||||
return self // No valid range found
|
||||
}
|
||||
|
||||
let startAltitude = startLocation.altitude
|
||||
let endAltitude = endLocation.altitude
|
||||
let duration = endDate.timeIntervalSince(startDate)
|
||||
|
||||
return map { loc in
|
||||
guard loc.timestamp >= startDate && loc.timestamp <= endDate else {
|
||||
return loc // outside window, unchanged
|
||||
}
|
||||
let progress = (loc.timestamp.timeIntervalSince(startDate)) / duration
|
||||
let newAltitude = startAltitude + progress * (endAltitude - startAltitude)
|
||||
|
||||
return CLLocation(
|
||||
coordinate: loc.coordinate,
|
||||
altitude: newAltitude,
|
||||
horizontalAccuracy: loc.horizontalAccuracy,
|
||||
verticalAccuracy: loc.verticalAccuracy,
|
||||
course: loc.course,
|
||||
speed: loc.speed,
|
||||
timestamp: loc.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
128
CHDataManagement/Workouts/MapImageCreator.swift
Normal file
128
CHDataManagement/Workouts/MapImageCreator.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import MapKit
|
||||
|
||||
struct MapImageCreator {
|
||||
|
||||
let locations: [CLLocation]
|
||||
|
||||
func createMapSnapshot(
|
||||
size layoutSize: CGSize,
|
||||
scale: CGFloat = 2.0,
|
||||
lineWidth: CGFloat = 5,
|
||||
paddingFactor: Double = 1.2,
|
||||
lineColor: NSColor = .systemBlue
|
||||
) -> (image: NSImage, imagePoints: [CGPoint])? {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
var result: (image: NSImage, imagePoints: [CGPoint])?
|
||||
|
||||
self.createMapSnapshot(
|
||||
size: layoutSize,
|
||||
scale: scale,
|
||||
lineWidth: lineWidth,
|
||||
paddingFactor: paddingFactor,
|
||||
lineColor: lineColor
|
||||
) { res in
|
||||
result = res
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
semaphore.wait()
|
||||
return result
|
||||
}
|
||||
|
||||
func createMapSnapshot(
|
||||
size layoutSize: CGSize,
|
||||
scale: CGFloat = 2.0,
|
||||
lineWidth: CGFloat = 5,
|
||||
paddingFactor: Double = 1.2,
|
||||
lineColor: NSColor = .systemBlue
|
||||
) async -> (image: NSImage, imagePoints: [CGPoint])? {
|
||||
await withCheckedContinuation { c in
|
||||
self.createMapSnapshot(
|
||||
size: layoutSize,
|
||||
scale: scale,
|
||||
lineWidth: lineWidth,
|
||||
paddingFactor: paddingFactor,
|
||||
lineColor: lineColor
|
||||
) { c.resume(returning: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
func createMapSnapshot(
|
||||
size layoutSize: CGSize,
|
||||
scale: CGFloat = 2.0,
|
||||
lineWidth: CGFloat = 5,
|
||||
paddingFactor: Double = 1.2,
|
||||
lineColor: NSColor = .systemBlue,
|
||||
completion: @escaping ((image: NSImage, imagePoints: [CGPoint])?) -> Void
|
||||
) {
|
||||
guard !locations.isEmpty else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
let coordinates = locations.map { $0.coordinate }
|
||||
|
||||
let pixelSize = CGSize(width: layoutSize.width * scale, height: layoutSize.height * scale)
|
||||
|
||||
let options = MKMapSnapshotter.Options()
|
||||
options.size = pixelSize
|
||||
options.preferredConfiguration = MKHybridMapConfiguration(elevationStyle: .flat)
|
||||
|
||||
let polyline = MKPolyline(coordinates: coordinates, count: coordinates.count)
|
||||
let boundingMapRect = polyline.boundingMapRect
|
||||
let region = MKCoordinateRegion(boundingMapRect)
|
||||
|
||||
let latDelta = region.span.latitudeDelta * paddingFactor
|
||||
let lonDelta = region.span.longitudeDelta * paddingFactor
|
||||
let paddedRegion = MKCoordinateRegion(
|
||||
center: region.center,
|
||||
span: MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta)
|
||||
)
|
||||
|
||||
options.region = paddedRegion
|
||||
|
||||
let snapshotter = MKMapSnapshotter(options: options)
|
||||
snapshotter.start { snapshotOrNil, error in
|
||||
guard let snapshot = snapshotOrNil, error == nil else {
|
||||
print("Snapshot error: \(error?.localizedDescription ?? "unknown error")")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let image = NSImage(size: pixelSize)
|
||||
image.lockFocus()
|
||||
|
||||
snapshot.image.draw(in: CGRect(origin: .zero, size: pixelSize))
|
||||
|
||||
let path = NSBezierPath()
|
||||
path.lineJoinStyle = .round
|
||||
let imagePoints = coordinates.map { snapshot.point(for: $0) }
|
||||
|
||||
if let first = imagePoints.first {
|
||||
path.move(to: first)
|
||||
for point in imagePoints.dropFirst() {
|
||||
path.line(to: point)
|
||||
}
|
||||
|
||||
lineColor.setStroke()
|
||||
path.lineWidth = lineWidth * scale
|
||||
path.stroke()
|
||||
}
|
||||
|
||||
image.unlockFocus()
|
||||
|
||||
// Recalculate imagePoints since they were inside the drawing block
|
||||
let widthFactor = 1 / pixelSize.width
|
||||
let heightFactor = 1 / pixelSize.height
|
||||
let finalImagePoints = coordinates.map { coordinate in
|
||||
let point = snapshot.point(for: coordinate)
|
||||
return CGPoint(x: point.x * widthFactor,
|
||||
y: point.y * heightFactor)
|
||||
}
|
||||
|
||||
completion((image, finalImagePoints))
|
||||
}
|
||||
}
|
||||
}
|
||||
200
CHDataManagement/Workouts/Point.swift
Normal file
200
CHDataManagement/Workouts/Point.swift
Normal file
@@ -0,0 +1,200 @@
|
||||
|
||||
enum SamplingMode {
|
||||
case cumulative // e.g., distance, energy
|
||||
case instantaneous // e.g., heart rate, altitude
|
||||
}
|
||||
|
||||
struct Point: Identifiable, Equatable, Hashable {
|
||||
|
||||
var x, y: Double
|
||||
|
||||
var id: Double { x }
|
||||
|
||||
/**
|
||||
Interpolate the y value at an x coordinate toward another point.
|
||||
*/
|
||||
func interpolate(to other: Point, at location: Double) -> Double {
|
||||
let totalX = other.x - x
|
||||
if totalX == 0 {
|
||||
return (y + other.y) / 2
|
||||
}
|
||||
let diffX = location - x
|
||||
if diffX == 0 {
|
||||
return y
|
||||
}
|
||||
let ratio = diffX / totalX
|
||||
return y + (other.y - y) * ratio
|
||||
}
|
||||
|
||||
static let zero = Point(x: 0, y: 0)
|
||||
}
|
||||
|
||||
extension Array where Element == Point {
|
||||
|
||||
func resample(numberOfSamples: Int, minX: Double? = nil, maxX: Double? = nil, mode: SamplingMode) -> [Point] {
|
||||
guard count >= 2, numberOfSamples > 0 else { return [] }
|
||||
let firstX = minX ?? first!.x
|
||||
let lastX = maxX ?? last!.x
|
||||
let totalDuration = lastX - firstX
|
||||
guard totalDuration > 0 else { return [] }
|
||||
|
||||
let interval = totalDuration / Double(numberOfSamples)
|
||||
var result: [Point] = .init(repeating: .zero, count: numberOfSamples)
|
||||
|
||||
var currentIndex = 0
|
||||
var current = self[0]
|
||||
|
||||
for i in 0..<numberOfSamples {
|
||||
let startOfInterval = Double(i) * interval
|
||||
let endOfInterval = startOfInterval + interval
|
||||
|
||||
var accumulated = 0.0
|
||||
var accumulatedDuration = 0.0
|
||||
|
||||
while currentIndex < count - 1 {
|
||||
let next = self[currentIndex + 1]
|
||||
|
||||
if next.x <= endOfInterval {
|
||||
let dt = next.x - current.x
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
accumulated += next.y - current.y
|
||||
case .instantaneous:
|
||||
accumulated += (current.y + next.y) / 2 * dt
|
||||
accumulatedDuration += dt
|
||||
}
|
||||
currentIndex += 1
|
||||
current = next
|
||||
} else {
|
||||
// partial segment at the interval end
|
||||
let dt = endOfInterval - current.x
|
||||
let slope = (next.y - current.y) / (next.x - current.x)
|
||||
let interpolatedY = current.y + slope * dt
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
accumulated += interpolatedY - current.y
|
||||
case .instantaneous:
|
||||
accumulated += (current.y + interpolatedY) / 2 * dt
|
||||
accumulatedDuration += dt
|
||||
}
|
||||
current = Point(x: endOfInterval, y: interpolatedY)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
result[i].x = (startOfInterval + endOfInterval) / 2 // midpoint
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
result[i].y = accumulated / interval // average rate over interval
|
||||
case .instantaneous:
|
||||
result[i].y = accumulatedDuration > 0 ? accumulated / accumulatedDuration : current.y
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resample(
|
||||
numberOfSamples: Int,
|
||||
minX: Double? = nil,
|
||||
maxX: Double? = nil,
|
||||
mode: SamplingMode
|
||||
) -> [Double] {
|
||||
guard count >= 2, numberOfSamples > 0 else { return [] }
|
||||
let minX = minX ?? first!.x
|
||||
let maxX = maxX ?? last!.x
|
||||
let totalDuration = maxX - minX
|
||||
guard totalDuration > 0 else { return [] }
|
||||
|
||||
let interval = totalDuration / Double(numberOfSamples)
|
||||
var result = [Double](repeating: 0.0, count: numberOfSamples)
|
||||
|
||||
var currentIndex = 0
|
||||
var current = self[0]
|
||||
|
||||
for i in 0..<numberOfSamples {
|
||||
let startOfInterval = minX + Double(i) * interval
|
||||
let endOfInterval = startOfInterval + interval
|
||||
|
||||
var accumulated = 0.0
|
||||
var accumulatedDuration = 0.0
|
||||
|
||||
while currentIndex < count - 1 {
|
||||
let next = self[currentIndex + 1]
|
||||
|
||||
if next.x <= endOfInterval {
|
||||
let dt = next.x - current.x
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
accumulated += next.y - current.y
|
||||
case .instantaneous:
|
||||
accumulated += (current.y + next.y) / 2 * dt
|
||||
accumulatedDuration += dt
|
||||
}
|
||||
currentIndex += 1
|
||||
current = next
|
||||
} else {
|
||||
let dt = endOfInterval - current.x
|
||||
let slope = (next.y - current.y) / (next.x - current.x)
|
||||
let interpolatedY = current.y + slope * dt
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
accumulated += interpolatedY - current.y
|
||||
case .instantaneous:
|
||||
accumulated += (current.y + interpolatedY) / 2 * dt
|
||||
accumulatedDuration += dt
|
||||
}
|
||||
current = Point(x: endOfInterval, y: interpolatedY)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let value: Double
|
||||
switch mode {
|
||||
case .cumulative:
|
||||
value = accumulated / interval
|
||||
case .instantaneous:
|
||||
value = accumulatedDuration > 0 ? accumulated / accumulatedDuration : current.y
|
||||
}
|
||||
result[i] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence {
|
||||
/// Resamples any sequence of elements into evenly spaced intervals.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - numberOfSamples: Number of output points
|
||||
/// - xSelector: Closure to get the independent variable (e.g., time)
|
||||
/// - ySelector: Closure to get the dependent variable (e.g., distance, heart rate)
|
||||
/// - mode: .cumulative or .instantaneous
|
||||
/// - Returns: Array of Points with evenly spaced `x` and averaged `y`
|
||||
func resample(
|
||||
numberOfSamples: Int,
|
||||
x: (Element) -> Double,
|
||||
y: (Element) -> Double,
|
||||
mode: SamplingMode
|
||||
) -> [Point] {
|
||||
let points = self.map { Point(x: x($0), y: y($0)) }
|
||||
return points.resample(numberOfSamples: numberOfSamples, mode: mode)
|
||||
}
|
||||
|
||||
func resample(
|
||||
numberOfSamples: Int,
|
||||
minX: Double? = nil,
|
||||
maxX: Double? = nil,
|
||||
x: (Element) -> Double,
|
||||
y: (Element) -> Double,
|
||||
mode: SamplingMode
|
||||
) -> [Double] {
|
||||
let points = self.map { Point(x: x($0), y: y($0)) }
|
||||
return points.resample(
|
||||
numberOfSamples: numberOfSamples,
|
||||
minX: minX,
|
||||
maxX: maxX,
|
||||
mode: mode)
|
||||
}
|
||||
|
||||
}
|
||||
54
CHDataManagement/Workouts/RouteOverview.swift
Normal file
54
CHDataManagement/Workouts/RouteOverview.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
struct RouteOverview {
|
||||
|
||||
/// The total active energy in kcal
|
||||
let energy: Double
|
||||
|
||||
/// The total distance of the track in meters
|
||||
let distance: Double
|
||||
|
||||
/// The total duration in seconds
|
||||
let duration: TimeInterval
|
||||
|
||||
/// The total ascended altitude in meters
|
||||
let ascendedElevation: Double
|
||||
|
||||
let start: Date?
|
||||
|
||||
let end: Date?
|
||||
|
||||
static func combine(_ overviews: [RouteOverview]) -> RouteOverview {
|
||||
RouteOverview(
|
||||
energy: overviews.reduce(0) { $0 + $1.energy },
|
||||
distance: overviews.reduce(0) { $0 + $1.distance },
|
||||
duration: overviews.reduce(0) { $0 + $1.duration },
|
||||
ascendedElevation: overviews.reduce(0) { $0 + $1.ascendedElevation },
|
||||
start: overviews.compactMap { $0.start }.min(),
|
||||
end: overviews.compactMap { $0.end }.max())
|
||||
}
|
||||
|
||||
var days: Int {
|
||||
guard let start, let end else {
|
||||
return 0
|
||||
}
|
||||
return start.inclusiveDays(to: end)
|
||||
}
|
||||
}
|
||||
|
||||
extension RouteOverview {
|
||||
|
||||
func update(labels: inout [ContentLabel], language: ContentLanguage) {
|
||||
let locale = language.locale
|
||||
let days = self.days
|
||||
|
||||
if days != 1 {
|
||||
labels.insertOrReplace(icon: .calendar, value: language.text(days: days))
|
||||
} else {
|
||||
labels.insertOrReplace(icon: .statisticsTime, value: duration.duration(locale: locale))
|
||||
}
|
||||
labels.insertOrReplace(icon: .statisticsDistance, value: String(format: "%.1f km", locale: locale, distance / 1000))
|
||||
labels.insertOrReplace(icon: .statisticsElevationUp, value: ascendedElevation.length(roundingToNearest: 50))
|
||||
labels.insertOrReplace(icon: .statisticsEnergy, value: energy.energy(roundingToNearest: 50))
|
||||
}
|
||||
}
|
||||
87
CHDataManagement/Workouts/RoutePreviewView.swift
Normal file
87
CHDataManagement/Workouts/RoutePreviewView.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
import CoreLocation
|
||||
|
||||
|
||||
struct RoutePreviewView: View {
|
||||
|
||||
private let iconSize: CGFloat = 150
|
||||
|
||||
@ObservedObject
|
||||
var file: FileResource
|
||||
|
||||
@State
|
||||
var overview: RouteOverview?
|
||||
|
||||
@State
|
||||
var message: String?
|
||||
|
||||
@State
|
||||
var elevation: [ElevationSample] = []
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Image(systemSymbol: .map)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode:.fit)
|
||||
.frame(width: iconSize)
|
||||
if let message {
|
||||
Text(message)
|
||||
.font(.title)
|
||||
} else if let overview {
|
||||
if let start = overview.start {
|
||||
if let end = overview.end {
|
||||
Text("\(start.formatted()) - \(end.formatted()) (\(overview.duration.timeString))")
|
||||
} else {
|
||||
Text(start.formatted())
|
||||
}
|
||||
}
|
||||
Text(String(format: "%.2f km (%.0f m ascended)", overview.distance / 1000, overview.ascendedElevation))
|
||||
Text("\(Int(overview.energy)) kcal")
|
||||
if !elevation.isEmpty {
|
||||
ElevationGraph(samples: elevation)
|
||||
.frame(width: 500, height: 200)
|
||||
.padding()
|
||||
}
|
||||
} else {
|
||||
Text("Loading route overview...")
|
||||
.font(.title)
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.onAppear { loadOverview() }
|
||||
}
|
||||
|
||||
private func loadOverview() {
|
||||
guard overview == nil && message == nil else {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
guard let data = file.dataContent() else {
|
||||
DispatchQueue.main.async {
|
||||
self.message = "Failed to get file data"
|
||||
}
|
||||
return
|
||||
}
|
||||
let route: WorkoutData
|
||||
do {
|
||||
route = try WorkoutData(data: data)
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.message = "Failed to decode route: \(error)"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let overview = route.overview
|
||||
|
||||
let elevations = route.locations.map { ElevationSample(timestamp: $0.timestamp, altitude: $0.altitude) }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.overview = overview
|
||||
self.elevation = elevations
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
127
CHDataManagement/Workouts/Sequence+Median.swift
Normal file
127
CHDataManagement/Workouts/Sequence+Median.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
// Store values with indices to handle duplicates uniquely
|
||||
private struct Entry<T: BinaryFloatingPoint>: Comparable {
|
||||
let index: Int
|
||||
let value: T
|
||||
static func < (lhs: Entry<T>, rhs: Entry<T>) -> Bool {
|
||||
lhs.value == rhs.value ? lhs.index < rhs.index : lhs.value < rhs.value
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence {
|
||||
|
||||
func firstElement() -> Element? {
|
||||
for element in self {
|
||||
return element
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func minMax<E>(by converting: (Element) -> E) -> (min: E, max: E)? where E: Comparable {
|
||||
guard let first = firstElement() else {
|
||||
return nil
|
||||
}
|
||||
var minimum = converting(first)
|
||||
var maximum = minimum
|
||||
for location in dropFirst() {
|
||||
let value = converting(location)
|
||||
if value < minimum {
|
||||
minimum = value
|
||||
} else if value > maximum {
|
||||
maximum = value
|
||||
}
|
||||
}
|
||||
return (minimum, maximum)
|
||||
}
|
||||
|
||||
/// Applies a centered median filter to the sequence.
|
||||
/// - Parameters:
|
||||
/// - windowSize: The number of samples in the median filter window (should be odd for symmetric centering).
|
||||
/// - transform: Closure to transform each element into a numeric value.
|
||||
/// - Returns: An array of filtered elements (same type as input).
|
||||
func medianFiltered<T: BinaryFloatingPoint>(windowSize: Int, transform: (Element) -> T) -> [Element] {
|
||||
precondition(windowSize > 0, "Window size must be greater than zero")
|
||||
let input = Array(self)
|
||||
guard !input.isEmpty else { return [] }
|
||||
|
||||
var result: [Element] = []
|
||||
result.reserveCapacity(input.count)
|
||||
|
||||
let halfWindow = windowSize / 2
|
||||
|
||||
for i in 0..<input.count {
|
||||
let start = Swift.max(0, i - halfWindow)
|
||||
let end = Swift.min(input.count - 1, i + halfWindow)
|
||||
var window: [Entry<T>] = []
|
||||
|
||||
for j in start...end {
|
||||
window.append(Entry(index: j, value: transform(input[j])))
|
||||
}
|
||||
|
||||
window.sort()
|
||||
|
||||
// Median position
|
||||
let medianIndex = window.count / 2
|
||||
let medianValue = window[medianIndex].value
|
||||
|
||||
// Choose the element closest to the median
|
||||
let closest = input[start...end]
|
||||
.min(by: { abs(Double(transform($0) - medianValue)) < abs(Double(transform($1) - medianValue)) })!
|
||||
|
||||
result.append(closest)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Default version when Element itself is BinaryFloatingPoint
|
||||
func medianFiltered(windowSize: Int) -> [Element] where Element: BinaryFloatingPoint {
|
||||
return self.medianFiltered(windowSize: windowSize, transform: { $0 })
|
||||
}
|
||||
|
||||
/// Iterate over adjacent pairs of elements in the sequence, applying a transform closure.
|
||||
/// - Parameter transform: A closure that takes two consecutive elements and returns a value of type T.
|
||||
/// - Returns: An array of transformed values.
|
||||
func adjacentPairs<T>(_ transform: (Element, Element) -> T) -> [T] {
|
||||
var result: [T] = []
|
||||
var iterator = self.makeIterator()
|
||||
guard var prev = iterator.next() else { return [] }
|
||||
while let current = iterator.next() {
|
||||
result.append(transform(prev, current))
|
||||
prev = current
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
extension Array where Element: Comparable {
|
||||
/// Binary search returning the index where `predicate` fails (insertion point).
|
||||
func binarySearch(predicate: (Element) -> Bool) -> Int {
|
||||
var low = 0
|
||||
var high = count
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
if predicate(self[mid]) {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
/// Binary search exact element index if present.
|
||||
func binarySearchExact(_ element: Element) -> Int? {
|
||||
var low = 0
|
||||
var high = count - 1
|
||||
while low <= high {
|
||||
let mid = (low + high) / 2
|
||||
if self[mid] == element { return mid }
|
||||
else if self[mid] < element { low = mid + 1 }
|
||||
else { high = mid - 1 }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
19
CHDataManagement/Workouts/Time+String.swift
Normal file
19
CHDataManagement/Workouts/Time+String.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
extension TimeInterval {
|
||||
|
||||
var timeString: String {
|
||||
let seconds = Int(rounded())
|
||||
guard seconds > 59 else {
|
||||
return "\(seconds) s"
|
||||
}
|
||||
let min = seconds / 60
|
||||
let secs = seconds % 60
|
||||
guard min > 59 else {
|
||||
return String(format: "%02d:%02d", min, secs)
|
||||
}
|
||||
let hours = min / 60
|
||||
let mins = min % 60
|
||||
return String(format: "%d:%02d:%02d", hours, mins, secs)
|
||||
}
|
||||
}
|
||||
221
CHDataManagement/Workouts/WorkoutData.swift
Normal file
221
CHDataManagement/Workouts/WorkoutData.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import BinaryCodable
|
||||
|
||||
private struct TrackLocation {
|
||||
|
||||
let timestamp: TimeInterval
|
||||
|
||||
let latitude: Double
|
||||
|
||||
let longitude: Double
|
||||
|
||||
let speed: Double?
|
||||
|
||||
let speedAccuracy: Double?
|
||||
|
||||
let course: Double?
|
||||
|
||||
let courseAccuracy: Double?
|
||||
|
||||
let elevation: Double
|
||||
|
||||
let horizontalAccuracy: Double?
|
||||
|
||||
let verticalAccuracy: Double?
|
||||
|
||||
init(
|
||||
timestamp: TimeInterval,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
speed: Double,
|
||||
speedAccuracy: Double? = nil,
|
||||
course: Double,
|
||||
courseAccuracy: Double? = nil,
|
||||
elevation: Double,
|
||||
horizontalAccuracy: Double? = nil,
|
||||
verticalAccuracy: Double? = nil
|
||||
) {
|
||||
self.timestamp = timestamp
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.speed = speed
|
||||
self.speedAccuracy = speedAccuracy
|
||||
self.course = course
|
||||
self.courseAccuracy = courseAccuracy
|
||||
self.elevation = elevation
|
||||
self.horizontalAccuracy = horizontalAccuracy
|
||||
self.verticalAccuracy = verticalAccuracy
|
||||
}
|
||||
|
||||
init(location: CLLocation) {
|
||||
self.timestamp = location.timestamp.timeIntervalSince1970
|
||||
self.elevation = location.altitude
|
||||
self.latitude = location.coordinate.latitude
|
||||
self.longitude = location.coordinate.longitude
|
||||
self.speed = location.speed
|
||||
self.speedAccuracy = location.speedAccuracy
|
||||
self.course = location.course
|
||||
self.courseAccuracy = location.courseAccuracy
|
||||
self.horizontalAccuracy = location.horizontalAccuracy
|
||||
self.verticalAccuracy = location.verticalAccuracy
|
||||
}
|
||||
|
||||
var location: CLLocation {
|
||||
.init(
|
||||
coordinate: .init(
|
||||
latitude: latitude,
|
||||
longitude: longitude),
|
||||
altitude: elevation,
|
||||
horizontalAccuracy: horizontalAccuracy ?? -1,
|
||||
verticalAccuracy: verticalAccuracy ?? -1,
|
||||
course: course ?? -1,
|
||||
courseAccuracy: courseAccuracy ?? -1,
|
||||
speed: speed ?? -1,
|
||||
speedAccuracy: speedAccuracy ?? -1,
|
||||
timestamp: .init(timeIntervalSince1970: timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
extension TrackLocation: Codable {
|
||||
|
||||
enum CodingKeys: Int, CodingKey {
|
||||
case timestamp = 1
|
||||
case latitude
|
||||
case longitude
|
||||
case speed
|
||||
case speedAccuracy
|
||||
case course
|
||||
case courseAccuracy
|
||||
case elevation
|
||||
case horizontalAccuracy
|
||||
case verticalAccuracy
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutData {
|
||||
|
||||
struct Sample {
|
||||
|
||||
/// The unix time
|
||||
let timestamp: TimeInterval
|
||||
|
||||
let value: Double
|
||||
|
||||
init(timestamp: TimeInterval, value: Double) {
|
||||
self.timestamp = timestamp
|
||||
self.value = value
|
||||
}
|
||||
|
||||
var time: Date {
|
||||
.init(timeIntervalSince1970: timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutData.Sample: Codable {
|
||||
|
||||
enum CodingKeys: Int, CodingKey {
|
||||
case timestamp = 1
|
||||
case value
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkoutData {
|
||||
|
||||
let locations: [CLLocation]
|
||||
|
||||
let heartRates: [Sample]
|
||||
|
||||
/// The active energy in kcal
|
||||
let energy: [Sample]
|
||||
|
||||
init(locations: [CLLocation], heartRates: [Sample], energy: [Sample]) {
|
||||
self.locations = locations
|
||||
self.heartRates = heartRates
|
||||
self.energy = energy
|
||||
}
|
||||
|
||||
func encoded() throws -> Data {
|
||||
let encoder = BinaryEncoder()
|
||||
return try encoder.encode(self)
|
||||
}
|
||||
|
||||
init(url: URL) throws {
|
||||
let data = try Data(contentsOf: url)
|
||||
try self.init(data: data)
|
||||
}
|
||||
|
||||
init(data: Data) throws {
|
||||
let decoder = BinaryDecoder()
|
||||
self = try decoder.decode(WorkoutData.self, from: data)
|
||||
}
|
||||
|
||||
/// The total active energy in kcal
|
||||
var totalEnergy: Double {
|
||||
energy.reduce(0) { $0 + $1.value }
|
||||
}
|
||||
|
||||
/// The total distance of the track in meters
|
||||
var totalDistance: CLLocationDistance {
|
||||
locations.minimumTraveledDistance3D()
|
||||
}
|
||||
|
||||
/// The total duration in seconds
|
||||
var totalDuration: TimeInterval {
|
||||
guard let start, let end else {
|
||||
return 0
|
||||
}
|
||||
return end.timeIntervalSince(start)
|
||||
}
|
||||
|
||||
/// The total ascended altitude in meters
|
||||
var totalAscendedElevation: CLLocationDistance {
|
||||
locations.minimumAscendedAltitude(threshold: 15)
|
||||
}
|
||||
|
||||
var overview: RouteOverview {
|
||||
.init(energy: totalEnergy,
|
||||
distance: totalDistance,
|
||||
duration: totalDuration,
|
||||
ascendedElevation: totalAscendedElevation,
|
||||
start: start,
|
||||
end: end)
|
||||
}
|
||||
|
||||
var start: Date? {
|
||||
let starts: [Date?] = [
|
||||
locations.first?.timestamp,
|
||||
heartRates.first?.time,
|
||||
energy.first?.time]
|
||||
return starts.compactMap { $0 }.min()
|
||||
}
|
||||
|
||||
var end: Date? {
|
||||
let ends: [Date?] = [locations.last?.timestamp, heartRates.last?.time, energy.last?.time]
|
||||
return ends.compactMap { $0 }.max()
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(locations.map(TrackLocation.init), forKey: .locations)
|
||||
try container.encode(heartRates, forKey: .heartRates)
|
||||
try container.encode(energy, forKey: .energy)
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.locations = try container.decode([TrackLocation].self, forKey: .locations).map { $0.location }
|
||||
self.heartRates = try container.decode([Sample].self, forKey: .heartRates)
|
||||
self.energy = try container.decode([Sample].self, forKey: .energy)
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutData: Codable {
|
||||
|
||||
enum CodingKeys: Int, CodingKey {
|
||||
case locations = 1
|
||||
case heartRates
|
||||
case energy
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user