Compare commits
26 Commits
4f31622abe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
544c31643f | ||
|
|
57fa5aa3dd | ||
|
|
07ba77e337 | ||
|
|
9848de02cb | ||
|
|
96bd07bdb7 | ||
|
|
f972a2c020 | ||
|
|
9ec207014c | ||
|
|
43b761b593 | ||
|
|
ba6097a67b | ||
|
|
5ac8991c48 | ||
|
|
8508719dbe | ||
|
|
1d0eba9d78 | ||
|
|
5970ce2e9f | ||
|
|
73d9c4ec29 | ||
|
|
ee2993318f | ||
|
|
afa2e0b844 | ||
|
|
d779b7a42c | ||
|
|
f968ccad29 | ||
|
|
a4710d525b | ||
|
|
a8920a4cd2 | ||
|
|
cb041eb6ed | ||
|
|
329519e15b | ||
|
|
d6502fb09c | ||
|
|
dd720d6646 | ||
|
|
e689903f3c | ||
|
|
062e7d289a |
@@ -7,6 +7,9 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
E2039A152E0001B700305538 /* PostImagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2039A142E0001B200305538 /* PostImagesView.swift */; };
|
||||
E2039A172E00027600305538 /* PostTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2039A162E00027300305538 /* PostTitleView.swift */; };
|
||||
E2039A192E0002C500305538 /* PostTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2039A182E0002C100305538 /* PostTextView.swift */; };
|
||||
E20BCC972D53454C00B8DBEB /* StorageItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCC962D53454500B8DBEB /* StorageItem.swift */; };
|
||||
E20BCC992D53597D00B8DBEB /* SaveState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCC982D53597D00B8DBEB /* SaveState.swift */; };
|
||||
E20BCC9B2D535C3500B8DBEB /* ChangeObservableItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCC9A2D535C3100B8DBEB /* ChangeObservableItem.swift */; };
|
||||
@@ -31,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 */; };
|
||||
@@ -93,6 +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 */; };
|
||||
@@ -170,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 */; };
|
||||
@@ -194,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 */; };
|
||||
@@ -207,6 +237,9 @@
|
||||
E2F3B3982DC54F9400CFA712 /* ChangeObservingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3972DC54F8600CFA712 /* ChangeObservingItem.swift */; };
|
||||
E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B39B2DC5542E00CFA712 /* LabelEditingView.swift */; };
|
||||
E2F3B39E2DC55B1C00CFA712 /* LabelCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B39D2DC55B1C00CFA712 /* LabelCreationView.swift */; };
|
||||
E2F3B3A22DC769C300CFA712 /* ColoredButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3A12DC769BF00CFA712 /* ColoredButton.swift */; };
|
||||
E2F3B3A42DC7DC2400CFA712 /* GenerationIssuesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3A32DC7DC1F00CFA712 /* GenerationIssuesView.swift */; };
|
||||
E2F3B3A62DC7F61600CFA712 /* GenerationIssuesActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3A52DC7F60E00CFA712 /* GenerationIssuesActionView.swift */; };
|
||||
E2FD1D0D2D2DBBA600B48627 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D0C2D2DBBA100B48627 /* LinkPreview.swift */; };
|
||||
E2FD1D192D2DC4F500B48627 /* LoadingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D182D2DC4F500B48627 /* LoadingContext.swift */; };
|
||||
E2FD1D1B2D2DC63800B48627 /* LinkPreviewDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */; };
|
||||
@@ -234,7 +267,6 @@
|
||||
E2FD1D5A2D477AB200B48627 /* InsertableItemsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D592D477AB200B48627 /* InsertableItemsView.swift */; };
|
||||
E2FD1D5C2D47EEB800B48627 /* LinkedPageTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D5B2D47EEB800B48627 /* LinkedPageTagView.swift */; };
|
||||
E2FD1D5E2D47EED200B48627 /* PostImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D5D2D47EED200B48627 /* PostImageView.swift */; };
|
||||
E2FD1D602D47EEEF00B48627 /* LocalizedPostContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D5F2D47EEEF00B48627 /* LocalizedPostContentView.swift */; };
|
||||
E2FD1D642D47EF4200B48627 /* DetailListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D632D47EF4200B48627 /* DetailListItem.swift */; };
|
||||
E2FD1D682D483CCF00B48627 /* Insert+Buttons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D672D483CCA00B48627 /* Insert+Buttons.swift */; };
|
||||
E2FE0EE62D15A0B5002963B7 /* GenerationResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0EE52D15A0B1002963B7 /* GenerationResults.swift */; };
|
||||
@@ -295,6 +327,9 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
E2039A142E0001B200305538 /* PostImagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostImagesView.swift; sourceTree = "<group>"; };
|
||||
E2039A162E00027300305538 /* PostTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTitleView.swift; sourceTree = "<group>"; };
|
||||
E2039A182E0002C100305538 /* PostTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTextView.swift; sourceTree = "<group>"; };
|
||||
E20BCC962D53454500B8DBEB /* StorageItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageItem.swift; sourceTree = "<group>"; };
|
||||
E20BCC982D53597D00B8DBEB /* SaveState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveState.swift; sourceTree = "<group>"; };
|
||||
E20BCC9A2D535C3100B8DBEB /* ChangeObservableItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeObservableItem.swift; sourceTree = "<group>"; };
|
||||
@@ -314,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>"; };
|
||||
@@ -373,6 +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>"; };
|
||||
@@ -450,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>"; };
|
||||
@@ -473,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>"; };
|
||||
@@ -490,6 +551,9 @@
|
||||
E2F3B3972DC54F8600CFA712 /* ChangeObservingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeObservingItem.swift; sourceTree = "<group>"; };
|
||||
E2F3B39B2DC5542E00CFA712 /* LabelEditingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelEditingView.swift; sourceTree = "<group>"; };
|
||||
E2F3B39D2DC55B1C00CFA712 /* LabelCreationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelCreationView.swift; sourceTree = "<group>"; };
|
||||
E2F3B3A12DC769BF00CFA712 /* ColoredButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColoredButton.swift; sourceTree = "<group>"; };
|
||||
E2F3B3A32DC7DC1F00CFA712 /* GenerationIssuesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationIssuesView.swift; sourceTree = "<group>"; };
|
||||
E2F3B3A52DC7F60E00CFA712 /* GenerationIssuesActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationIssuesActionView.swift; sourceTree = "<group>"; };
|
||||
E2FD1D0C2D2DBBA100B48627 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = "<group>"; };
|
||||
E2FD1D182D2DC4F500B48627 /* LoadingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingContext.swift; sourceTree = "<group>"; };
|
||||
E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewDetailView.swift; sourceTree = "<group>"; };
|
||||
@@ -516,7 +580,6 @@
|
||||
E2FD1D592D477AB200B48627 /* InsertableItemsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertableItemsView.swift; sourceTree = "<group>"; };
|
||||
E2FD1D5B2D47EEB800B48627 /* LinkedPageTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedPageTagView.swift; sourceTree = "<group>"; };
|
||||
E2FD1D5D2D47EED200B48627 /* PostImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostImageView.swift; sourceTree = "<group>"; };
|
||||
E2FD1D5F2D47EEEF00B48627 /* LocalizedPostContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostContentView.swift; sourceTree = "<group>"; };
|
||||
E2FD1D632D47EF4200B48627 /* DetailListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailListItem.swift; sourceTree = "<group>"; };
|
||||
E2FD1D672D483CCA00B48627 /* Insert+Buttons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Buttons.swift"; sourceTree = "<group>"; };
|
||||
E2FE0EE52D15A0B1002963B7 /* GenerationResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationResults.swift; sourceTree = "<group>"; };
|
||||
@@ -581,6 +644,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E224E0E52E56528F0031C2B0 /* BinaryCodable in Frameworks */,
|
||||
E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */,
|
||||
E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */,
|
||||
E2FD1D522D4644B400B48627 /* SVGView in Frameworks */,
|
||||
@@ -599,6 +663,8 @@
|
||||
E20BCCA02D53985500B8DBEB /* Generation */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2F3B3A52DC7F60E00CFA712 /* GenerationIssuesActionView.swift */,
|
||||
E2F3B3A32DC7DC1F00CFA712 /* GenerationIssuesView.swift */,
|
||||
E20BCCAE2D53F4A500B8DBEB /* GenerationStringIssuesView.swift */,
|
||||
E20BCCAC2D53F48100B8DBEB /* IssueStatus.swift */,
|
||||
E20BCCAA2D53B85300B8DBEB /* GenerationResultsIssueView.swift */,
|
||||
@@ -679,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 = (
|
||||
@@ -697,6 +783,7 @@
|
||||
E25DA53B2D0042EA00AEF16D /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E29A577D2E9E444000B19DA3 /* ToolSettings.swift */,
|
||||
E2FD1D2D2D37180600B48627 /* GeneralSettings.swift */,
|
||||
E2FE0F392D2B3E4E002963B7 /* AudioPlayerSettings.swift */,
|
||||
E2FE0F6D2D2D3685002963B7 /* LocalizedAudioPlayerSettings.swift */,
|
||||
@@ -724,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;
|
||||
@@ -829,11 +918,13 @@
|
||||
E2A21C372CB9A4F10060935B /* Generic */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2F3B3A12DC769BF00CFA712 /* ColoredButton.swift */,
|
||||
E229902F2D0F75CF009F8D77 /* BoolPropertyView.swift */,
|
||||
E22990312D0F7678009F8D77 /* DatePropertyView.swift */,
|
||||
E29D312F2D03A2BD0051B7F4 /* DescriptionField.swift */,
|
||||
E22990292D0F5A10009F8D77 /* DetailTitle.swift */,
|
||||
E22990252D0F5822009F8D77 /* FilePropertyView.swift */,
|
||||
E2DBA3BB2E5CC18000F1E143 /* FilesPropertyView.swift */,
|
||||
E2A21C0F2CB18B390060935B /* FlowHStack.swift */,
|
||||
E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */,
|
||||
E22990392D0F7E44009F8D77 /* GenericPropertyView.swift */,
|
||||
@@ -1008,6 +1099,9 @@
|
||||
E2FD1D3E2D46404900B48627 /* PostLabelsView.swift */,
|
||||
E29D31502D0616890051B7F4 /* PostListView.swift */,
|
||||
E218502A2CF790AC0090B18B /* PostContentView.swift */,
|
||||
E2039A182E0002C100305538 /* PostTextView.swift */,
|
||||
E2039A162E00027300305538 /* PostTitleView.swift */,
|
||||
E2039A142E0001B200305538 /* PostImagesView.swift */,
|
||||
E21850262CF3B42D0090B18B /* PostDetailView.swift */,
|
||||
E29D313E2D04822C0051B7F4 /* AddPostView.swift */,
|
||||
E21850222CF10C840090B18B /* TagSelectionView.swift */,
|
||||
@@ -1015,7 +1109,6 @@
|
||||
E2A21C072CB17B810060935B /* TagView.swift */,
|
||||
E29D31312D03B5610051B7F4 /* LocalizedPostDetailView.swift */,
|
||||
E2FD1D5D2D47EED200B48627 /* PostImageView.swift */,
|
||||
E2FD1D5F2D47EEEF00B48627 /* LocalizedPostContentView.swift */,
|
||||
E2FD1D5B2D47EEB800B48627 /* LinkedPageTagView.swift */,
|
||||
);
|
||||
path = Posts;
|
||||
@@ -1024,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 */,
|
||||
@@ -1040,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 = (
|
||||
@@ -1060,6 +1176,7 @@
|
||||
E2DD04722C276F31003BFF1F /* CHDataManagement */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E224E0D72E55074E0031C2B0 /* Workouts */,
|
||||
E2B482162D63AF6F005C309D /* Notifications */,
|
||||
E2B4820E2D5E9FF0005C309D /* Push */,
|
||||
E2B482012D5D1325005C309D /* Server */,
|
||||
@@ -1120,6 +1237,7 @@
|
||||
E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */,
|
||||
E2FD1D552D46CED500B48627 /* Insert+Labels.swift */,
|
||||
E2F3B3842DC49B4400CFA712 /* Insert+Gallery.swift */,
|
||||
E2720B872DF38BB200FDB543 /* Insert+Video.swift */,
|
||||
);
|
||||
path = Commands;
|
||||
sourceTree = "<group>";
|
||||
@@ -1161,6 +1279,7 @@
|
||||
E2FE0F342D2B27E6002963B7 /* Blocks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2DBA3B02E58F57800F1E143 /* WorkoutBlock.swift */,
|
||||
E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */,
|
||||
E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */,
|
||||
E2B482212D676BEB005C309D /* PhoneScreensBlock.swift */,
|
||||
@@ -1259,6 +1378,7 @@
|
||||
E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */,
|
||||
E2FD1D512D4644B400B48627 /* SVGView */,
|
||||
E2B481FF2D5D1136005C309D /* Vapor */,
|
||||
E224E0E42E56528F0031C2B0 /* BinaryCodable */,
|
||||
);
|
||||
productName = CHDataManagement;
|
||||
productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */;
|
||||
@@ -1272,7 +1392,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1540;
|
||||
LastUpgradeCheck = 1630;
|
||||
LastUpgradeCheck = 2600;
|
||||
TargetAttributes = {
|
||||
E2DD046F2C276F31003BFF1F = {
|
||||
CreatedOnToolsVersion = 15.4;
|
||||
@@ -1298,6 +1418,7 @@
|
||||
E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
||||
E2FD1D502D4644B400B48627 /* XCRemoteSwiftPackageReference "SVGView" */,
|
||||
E2B481FE2D5D1136005C309D /* XCRemoteSwiftPackageReference "vapor" */,
|
||||
E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */,
|
||||
);
|
||||
productRefGroup = E2DD04712C276F31003BFF1F /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -1327,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 */,
|
||||
@@ -1340,6 +1462,7 @@
|
||||
E29D317D2D086AB00051B7F4 /* Int+Random.swift in Sources */,
|
||||
E2BF1BC62D6B16FF003089F1 /* HeadlineLink.swift in Sources */,
|
||||
E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */,
|
||||
E2039A192E0002C500305538 /* PostTextView.swift in Sources */,
|
||||
E2521E042D51796000C56662 /* StorageStatusView.swift in Sources */,
|
||||
E29D313B2D04464A0051B7F4 /* LocalizedTagDetailView.swift in Sources */,
|
||||
E2FE0F552D2BCFC4002963B7 /* ContentBlock.swift in Sources */,
|
||||
@@ -1357,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 */,
|
||||
@@ -1369,6 +1493,7 @@
|
||||
E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */,
|
||||
E2B85F452C429ED60047CD0C /* ImageGallery.swift in Sources */,
|
||||
E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */,
|
||||
E2039A152E0001B700305538 /* PostImagesView.swift in Sources */,
|
||||
E29D31852D0AE8EE0051B7F4 /* KnownHeaderElement.swift in Sources */,
|
||||
E25DA51B2CFF08BB00AEF16D /* PostFeedPageNavigation.swift in Sources */,
|
||||
E22990422D107A95009F8D77 /* ImageVersion.swift in Sources */,
|
||||
@@ -1378,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 */,
|
||||
@@ -1398,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 */,
|
||||
@@ -1405,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 */,
|
||||
@@ -1416,21 +1544,26 @@
|
||||
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 */,
|
||||
E29D31262D0370A80051B7F4 /* VideoCommand+Option.swift in Sources */,
|
||||
E2FE0EF82D1D8110002963B7 /* IconCommand.swift in Sources */,
|
||||
E2039A172E00027600305538 /* PostTitleView.swift in Sources */,
|
||||
E2F3B39E2DC55B1C00CFA712 /* LabelCreationView.swift in Sources */,
|
||||
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 */,
|
||||
@@ -1441,19 +1574,24 @@
|
||||
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 */,
|
||||
E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */,
|
||||
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 */,
|
||||
@@ -1462,8 +1600,10 @@
|
||||
E2FE0EEE2D1C22F3002963B7 /* MarkdownLinkProcessor.swift in Sources */,
|
||||
E2FE0F602D2C0422002963B7 /* VideoBlock.swift in Sources */,
|
||||
E2B482032D5D1331005C309D /* WebServer.swift in Sources */,
|
||||
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 */,
|
||||
@@ -1485,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 */,
|
||||
@@ -1492,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 */,
|
||||
@@ -1522,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 */,
|
||||
@@ -1547,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 */,
|
||||
@@ -1556,7 +1702,9 @@
|
||||
E2FE0F6C2D2D335E002963B7 /* LocalizedPageSettingsView.swift in Sources */,
|
||||
E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */,
|
||||
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 */,
|
||||
@@ -1571,16 +1719,20 @@
|
||||
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 */,
|
||||
E2FD1D0D2D2DBBA600B48627 /* LinkPreview.swift in Sources */,
|
||||
E20BCC972D53454C00B8DBEB /* StorageItem.swift in Sources */,
|
||||
E22990362D0F79D2009F8D77 /* OptionalStringPropertyView.swift in Sources */,
|
||||
E2FD1D602D47EEEF00B48627 /* LocalizedPostContentView.swift in Sources */,
|
||||
E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */,
|
||||
E2FD1D582D477A9400B48627 /* InsertableCommand.swift in Sources */,
|
||||
E2EC1FB42DC0FA8700C41784 /* Insert+Route.swift in Sources */,
|
||||
E2F3B3A62DC7F61600CFA712 /* GenerationIssuesActionView.swift in Sources */,
|
||||
E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */,
|
||||
E2FE0F222D2A84A0002963B7 /* VideoCommand.swift in Sources */,
|
||||
E2FE0F192D2723E3002963B7 /* ImageSet.swift in Sources */,
|
||||
@@ -1663,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";
|
||||
};
|
||||
@@ -1719,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;
|
||||
@@ -1751,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.1.1;
|
||||
MARKETING_VERSION = 1.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
@@ -1790,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.1.1;
|
||||
MARKETING_VERSION = 1.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
@@ -1825,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";
|
||||
@@ -1900,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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -37,7 +37,7 @@ struct GalleryBlock: BlockLineProcessor {
|
||||
$0.imageSet(width: imageWidth, height: imageWidth, language: language)
|
||||
}
|
||||
imageSets.forEach(results.require)
|
||||
let id = firstImage.id.replacingOccurrences(of: ".", with: "-")
|
||||
let id = firstImage.identifier.replacingOccurrences(of: ".", with: "-")
|
||||
let gallery = ImageGallery(id: id, images: imageSets, standalone: true)
|
||||
results.require(footer: gallery.standaloneFooter)
|
||||
results.require(headers: .swiperJs, .swiperCss)
|
||||
|
||||
@@ -50,7 +50,7 @@ struct PhoneScreensBlock: OrderedKeyBlockProcessor {
|
||||
}
|
||||
if key == .tall {
|
||||
if tall != nil {
|
||||
print("Another tall image: \(file.id)")
|
||||
print("Another tall image: \(file.identifier)")
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
@@ -69,7 +69,7 @@ struct PhoneScreensBlock: OrderedKeyBlockProcessor {
|
||||
}
|
||||
// key == .wide
|
||||
if wide != nil {
|
||||
print("Another wide image: \(file.id)")
|
||||
print("Another wide image: \(file.identifier)")
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -258,7 +267,7 @@ extension VideoBlock.Option {
|
||||
|
||||
Note: The `preload` attribute is ignored if `autoplay` is present.
|
||||
*/
|
||||
enum Preload: String {
|
||||
enum Preload: String, CaseIterable {
|
||||
|
||||
/// The author thinks that the browser should load the entire video when the page loads
|
||||
case auto
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -156,15 +156,18 @@ struct HtmlCommand: CommandProcessor {
|
||||
return
|
||||
}
|
||||
if findFile(withAbsolutePath: path) {
|
||||
// File marked as required
|
||||
return
|
||||
}
|
||||
|
||||
let fileId = path.dropBeforeLast("/")
|
||||
if content.isValidIdForFile(fileId) {
|
||||
results.missing(file: fileId, source: "HTML: \(source)")
|
||||
} else {
|
||||
results.warning("Could not find file '\(path)' for \(source)")
|
||||
}
|
||||
results.requiredOutput(path.withLeadingSlashRemoved, source: "HTML: \(source)")
|
||||
|
||||
// let fileId = path.dropBeforeLast("/")
|
||||
// if content.isValidIdForFile(fileId) {
|
||||
// results.missing(file: fileId, source: "HTML: \(source)")
|
||||
// } else {
|
||||
// results.warning("Could not find file '\(path)' for \(source)")
|
||||
// }
|
||||
}
|
||||
|
||||
private func findFile(withAbsolutePath absolutePath: String) -> Bool {
|
||||
@@ -185,7 +188,7 @@ struct HtmlCommand: CommandProcessor {
|
||||
results.missing(file: fileId, source: "HTML: \(source)")
|
||||
return
|
||||
}
|
||||
results.warning("Could not determine image version for file '\(file.id)' for \(source)")
|
||||
results.warning("Could not determine image version for file '\(file.identifier)' for \(source)")
|
||||
}
|
||||
|
||||
private func findFileWith(relativePath: String, type: FileType, source: String) {
|
||||
|
||||
@@ -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,21 +59,24 @@ final class ImageGenerator {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
guard let data = version.image.dataContent() else {
|
||||
print("ImageGenerator: Failed to load data for image \(version.image.id)")
|
||||
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.id)")
|
||||
print("ImageGenerator: Failed to load image \(version.image.identifier)")
|
||||
return false
|
||||
}
|
||||
|
||||
let representation = create(image: originalImage, width: CGFloat(version.maximumWidth), height: CGFloat(version.maximumHeight))
|
||||
|
||||
guard let data = create(image: representation, type: version.type, quality: version.quality) else {
|
||||
print("ImageGenerator: Failed to get data for type \(version.type) of image \(version.image.id)")
|
||||
print("ImageGenerator: Failed to get data for type \(version.type) of 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
|
||||
@@ -161,7 +199,7 @@ final class ImageGenerator {
|
||||
process.waitUntilExit()
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
print("ImageGenerator: Failed to create AVIF image \(version.image.id)")
|
||||
print("ImageGenerator: Failed to create AVIF image \(version.image.identifier)")
|
||||
let outputData = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let outputString = String(data: outputData, encoding: .utf8) ?? ""
|
||||
print(outputString)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -56,14 +56,14 @@ struct ImageVersion {
|
||||
extension ImageVersion: Identifiable {
|
||||
|
||||
var id: String {
|
||||
image.id + "-" + versionId
|
||||
image.identifier + "-" + versionId
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageVersion: Equatable {
|
||||
|
||||
static func == (lhs: ImageVersion, rhs: ImageVersion) -> Bool {
|
||||
lhs.image.id == rhs.image.id &&
|
||||
lhs.image.identifier == rhs.image.identifier &&
|
||||
lhs.maximumWidth == rhs.maximumWidth &&
|
||||
lhs.maximumHeight == rhs.maximumHeight &&
|
||||
lhs.type == rhs.type
|
||||
@@ -73,7 +73,7 @@ extension ImageVersion: Equatable {
|
||||
extension ImageVersion: Hashable {
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(image.id)
|
||||
hasher.combine(image.identifier)
|
||||
hasher.combine(maximumWidth)
|
||||
hasher.combine(maximumHeight)
|
||||
hasher.combine(type)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ final class PageGenerator {
|
||||
language: language, results: results)
|
||||
|
||||
let rawPageContent: String
|
||||
if let existing = content.storage.pageContent(for: page.id, language: language) {
|
||||
if let existing = content.storage.pageContent(for: page.identifier, language: language) {
|
||||
rawPageContent = existing
|
||||
} else {
|
||||
rawPageContent = makeEmptyPageContent(in: language)
|
||||
|
||||
@@ -161,7 +161,7 @@ final class TagOverviewGenerator {
|
||||
return imageSet
|
||||
}
|
||||
|
||||
content += RelatedPageLink(
|
||||
content += RelatedPageLink(
|
||||
title: tag.title,
|
||||
description: description,
|
||||
url: tag.url,
|
||||
|
||||
@@ -44,7 +44,7 @@ struct PostContentGenerator {
|
||||
}
|
||||
|
||||
private var postDescription: String {
|
||||
"content of post \(post.id) (\(language.shortText))"
|
||||
"content of post \(post.identifier) (\(language.shortText))"
|
||||
}
|
||||
|
||||
private func handleLink(
|
||||
@@ -54,9 +54,7 @@ struct PostContentGenerator {
|
||||
let converter = MarkdownLinkProcessor(content: content, results: self, language: language)
|
||||
let markdownUrl = markdown.between("(", and: ")")
|
||||
let text = markdown.between("[", and: "]")
|
||||
guard let url = converter.convert(markdownUrl: markdownUrl)?.url else {
|
||||
return text
|
||||
}
|
||||
let url = converter.convert(markdownUrl: markdownUrl)?.url ?? markdownUrl
|
||||
return "<span class='link' onclick=\"location.href='\(url)'; event.stopPropagation();\">\(text)</span>"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -95,7 +106,7 @@ final class PostListPageGenerator {
|
||||
post: post).generate()
|
||||
|
||||
return FeedEntryData(
|
||||
entryId: post.id,
|
||||
entryId: post.identifier,
|
||||
title: localized.title,
|
||||
textAboveTitle: post.dateText(in: language),
|
||||
link: linkUrl,
|
||||
|
||||
@@ -49,12 +49,17 @@ final class GenerationResults: ObservableObject {
|
||||
@Published
|
||||
var emptyPages: Set<LocalizedPageId> = []
|
||||
|
||||
/// The paths to the files in the output folder, without leading slashes
|
||||
@Published
|
||||
var outputFiles: Set<String> = []
|
||||
|
||||
@Published
|
||||
var unusedFilesInOutput: Set<String> = []
|
||||
|
||||
/// The paths to files required to be in the output folder, without leading slashes
|
||||
@Published
|
||||
var requiredOutputFiles: Set<String> = []
|
||||
|
||||
/**
|
||||
The url redirects to install to prevent broken links.
|
||||
|
||||
@@ -126,6 +131,7 @@ final class GenerationResults: ObservableObject {
|
||||
self.redirects = [:]
|
||||
self.outputFiles = []
|
||||
self.unusedFilesInOutput = []
|
||||
self.requiredOutputFiles = []
|
||||
}
|
||||
for result in cache.values {
|
||||
result.reset()
|
||||
@@ -161,7 +167,7 @@ final class GenerationResults: ObservableObject {
|
||||
update { self.unsavedOutputFiles = unsavedOutputFiles }
|
||||
let emptyPages = cache.values.filter { $0.pageIsEmpty }.map { $0.itemId }.compactMap { id -> LocalizedPageId? in
|
||||
guard case .page(let page) = id.itemType else { return nil }
|
||||
return LocalizedPageId(language: id.language, pageId: page.id)
|
||||
return LocalizedPageId(language: id.language, pageId: page.identifier)
|
||||
}.asSet()
|
||||
update { self.emptyPages = emptyPages }
|
||||
let redirects = cache.values.compactMap { $0.redirect }.reduce(into: [:]) { $0[$1.originalUrl] = $1.newUrl }
|
||||
@@ -257,9 +263,45 @@ final class GenerationResults: ObservableObject {
|
||||
}
|
||||
|
||||
func determineFiles(unusedIn existingFiles: Set<String>) {
|
||||
let unused = existingFiles.subtracting(outputFiles)
|
||||
// All paths with leading without leading slashes
|
||||
let unused = existingFiles.subtracting(outputFiles).subtracting(requiredOutputFiles)
|
||||
update { self.unusedFilesInOutput = unused }
|
||||
}
|
||||
|
||||
func determineMissingRequiredFiles(existingFiles: Set<String>) {
|
||||
// All paths with leading without leading slashes
|
||||
|
||||
// Check the files required in the output against the existing files,
|
||||
// and flag missing ones
|
||||
let externalFilePaths = self.externalFiles.map { $0.absoluteUrl.withLeadingSlashRemoved }
|
||||
let fullFiles = existingFiles.union(externalFilePaths)
|
||||
let missing = requiredOutputFiles.filter { path in
|
||||
!fullFiles.contains(path) &&
|
||||
!fullFiles.contains(path + ".html") &&
|
||||
!fullFiles.contains(path + "/1.html")
|
||||
}
|
||||
update { self.requiredOutputFiles = missing }
|
||||
}
|
||||
|
||||
func sources(forMissingPage page: String) -> [(page: LocalizedItemId, source: String)] {
|
||||
var all = [(page: LocalizedItemId, source: String)]()
|
||||
for (id, results) in cache {
|
||||
guard let sources = results.missingLinkedPages[page]?.sorted() else {
|
||||
continue
|
||||
}
|
||||
let additions = sources.map { (page: id, source: $0) }
|
||||
all.append(contentsOf: additions)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -11,7 +11,7 @@ extension ImageToGenerate: Hashable {
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(size)
|
||||
hasher.combine(image.id)
|
||||
hasher.combine(image.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ final class PageGenerationResults: ObservableObject {
|
||||
@Published
|
||||
private(set) var unsavedOutputFiles: [String: Set<ItemReference>] = [:]
|
||||
|
||||
/// The files that need to be present in the output folder
|
||||
@Published
|
||||
private(set) var requiredOutputFiles: [String: Set<String>] = [:]
|
||||
|
||||
private(set) var pageIsEmpty: Bool
|
||||
|
||||
private(set) var redirect: (originalUrl: String, newUrl: String)?
|
||||
@@ -120,6 +124,7 @@ final class PageGenerationResults: ObservableObject {
|
||||
invalidBlocks = []
|
||||
warnings = []
|
||||
unsavedOutputFiles = [:]
|
||||
requiredOutputFiles = [:]
|
||||
pageIsEmpty = false
|
||||
redirect = nil
|
||||
}
|
||||
@@ -151,6 +156,7 @@ final class PageGenerationResults: ObservableObject {
|
||||
self.invalidBlocks = []
|
||||
self.warnings = []
|
||||
self.unsavedOutputFiles = [:]
|
||||
self.requiredOutputFiles = [:]
|
||||
self.pageIsEmpty = false
|
||||
self.redirect = nil
|
||||
}
|
||||
@@ -258,6 +264,11 @@ final class PageGenerationResults: ObservableObject {
|
||||
onMain { self.requiredIcons.formUnion(icons) }
|
||||
}
|
||||
|
||||
func requiredOutput(_ path: String, source: String) {
|
||||
onMain { self.requiredOutputFiles[path, default: []].insert(source) }
|
||||
delegate.requiredOutputFile(path)
|
||||
}
|
||||
|
||||
func linked(to page: Page) {
|
||||
onMain { self.linkedPages.insert(page) }
|
||||
}
|
||||
@@ -284,7 +295,7 @@ final class PageGenerationResults: ObservableObject {
|
||||
func markPageAsEmpty() {
|
||||
guard case .page(let page) = itemId.itemType else { return }
|
||||
onMain { self.pageIsEmpty = true }
|
||||
delegate.empty(.init(language: itemId.language, pageId: page.id))
|
||||
delegate.empty(.init(language: itemId.language, pageId: page.identifier))
|
||||
}
|
||||
|
||||
func redirect(from originalUrl: String, to newUrl: String) {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,6 @@ struct MainView: App {
|
||||
@StateObject
|
||||
private var notifications: NotificationSender = .init()
|
||||
|
||||
@State
|
||||
private var language: ContentLanguage = .english
|
||||
|
||||
@StateObject
|
||||
private var selection: SelectedContent = .init()
|
||||
|
||||
@@ -131,7 +128,7 @@ struct MainView: App {
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Picker("", selection: $language) {
|
||||
Picker("", selection: $selection.language) {
|
||||
Text("English")
|
||||
.tag(ContentLanguage.english)
|
||||
Text("German")
|
||||
@@ -180,13 +177,13 @@ struct MainView: App {
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.environment(\.language, language)
|
||||
.environment(\.language, selection.language)
|
||||
.environmentObject(content)
|
||||
.environmentObject(selection)
|
||||
.onAppear(perform: loadContent)
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
addItemSheet
|
||||
.environment(\.language, language)
|
||||
.environment(\.language, selection.language)
|
||||
.environmentObject(content)
|
||||
.environmentObject(selection)
|
||||
}
|
||||
@@ -195,13 +192,14 @@ struct MainView: App {
|
||||
.environmentObject(content)
|
||||
}
|
||||
.sheet(isPresented: $showSettingsSheet) {
|
||||
SettingsSheet(language: $language)
|
||||
SettingsSheet(language: $selection.language)
|
||||
.environmentObject(content)
|
||||
.presentedWindowStyle(.titleBar)
|
||||
}
|
||||
.sheet(isPresented: $showGenerationSheet) {
|
||||
GenerationContentView()
|
||||
.environmentObject(content)
|
||||
.environmentObject(selection)
|
||||
}
|
||||
.sheet(isPresented: $showPreviewSheet) {
|
||||
WebsitePreviewSheet()
|
||||
|
||||
@@ -2,6 +2,9 @@ import Foundation
|
||||
|
||||
final class SelectedContent: ObservableObject {
|
||||
|
||||
@Published
|
||||
var language: ContentLanguage = .english
|
||||
|
||||
@Published
|
||||
var tab: MainViewTab = .posts
|
||||
|
||||
@@ -16,4 +19,28 @@ final class SelectedContent: ObservableObject {
|
||||
|
||||
@Published
|
||||
var file: FileResource?
|
||||
|
||||
func remove(_ post: Post) {
|
||||
if self.post == post {
|
||||
self.post = nil
|
||||
}
|
||||
}
|
||||
|
||||
func remove(_ page: Page) {
|
||||
if self.page == page {
|
||||
self.page = nil
|
||||
}
|
||||
}
|
||||
|
||||
func remove(_ tag: Tag) {
|
||||
if self.tag == tag {
|
||||
self.tag = nil
|
||||
}
|
||||
}
|
||||
|
||||
func remove(_ file: FileResource) {
|
||||
if self.file == file {
|
||||
self.file = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ struct SelectedContentView<Contained>: View where Contained: MainContentView {
|
||||
var body: some View {
|
||||
if let item = selected {
|
||||
Contained(item: item)
|
||||
.id(item.id)
|
||||
} else {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
@@ -20,6 +20,6 @@ extension FileResource {
|
||||
}
|
||||
|
||||
static var mock: FileResource {
|
||||
Content.mock.files.first(where: { $0.id == "my-file.txt" })!
|
||||
Content.mock.files.first(where: { $0.identifier == "my-file.txt" })!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ extension Page {
|
||||
lastModified: nil,
|
||||
originalUrl: "projects/electronics/my-first-project/en.html"),
|
||||
tags: [
|
||||
content.tags.first(where: { $0.id == "electronics" })!
|
||||
content.tags.first(where: { $0.identifier == "electronics" })!
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
static var empty: Page {
|
||||
Content.mock.pages.first(where: { $0.id == "my-id" })!
|
||||
Content.mock.pages.first(where: { $0.identifier == "my-id" })!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,9 @@ extension Post {
|
||||
startDate: .now,
|
||||
endDate: nil,
|
||||
tags: [
|
||||
content.tags.first(where: { $0.id == "nature" })!,
|
||||
content.tags.first(where: { $0.id == "sports" })!,
|
||||
content.tags.first(where: { $0.id == "hiking" })!
|
||||
content.tags.first(where: { $0.identifier == "nature" })!,
|
||||
content.tags.first(where: { $0.identifier == "sports" })!,
|
||||
content.tags.first(where: { $0.identifier == "hiking" })!
|
||||
],
|
||||
german: .init(
|
||||
content: content,
|
||||
@@ -47,44 +47,44 @@ extension Post {
|
||||
createdDate: .now,
|
||||
startDate: .now.addingTimeInterval(-86400), endDate: .now,
|
||||
tags: [
|
||||
content.tags.first(where: { $0.id == "nature" })!,
|
||||
content.tags.first(where: { $0.id == "sports" })!,
|
||||
content.tags.first(where: { $0.id == "hiking" })!,
|
||||
content.tags.first(where: { $0.id == "mountains" })!
|
||||
content.tags.first(where: { $0.identifier == "nature" })!,
|
||||
content.tags.first(where: { $0.identifier == "sports" })!,
|
||||
content.tags.first(where: { $0.identifier == "hiking" })!,
|
||||
content.tags.first(where: { $0.identifier == "mountains" })!
|
||||
],
|
||||
german: LocalizedPost(
|
||||
content: content,
|
||||
title: "Eine lange Wanderung",
|
||||
text: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend.",
|
||||
images: [
|
||||
content.files.first(where: { $0.id == "image1" })!,
|
||||
content.files.first(where: { $0.id == "image2" })!,
|
||||
content.files.first(where: { $0.id == "image3" })!,
|
||||
content.files.first(where: { $0.id == "image4" })!
|
||||
content.files.first(where: { $0.identifier == "image1" })!,
|
||||
content.files.first(where: { $0.identifier == "image2" })!,
|
||||
content.files.first(where: { $0.identifier == "image3" })!,
|
||||
content.files.first(where: { $0.identifier == "image4" })!
|
||||
]),
|
||||
english: LocalizedPost(
|
||||
content: content,
|
||||
title: "A longer hike",
|
||||
text: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.",
|
||||
images: [
|
||||
content.files.first(where: { $0.id == "image1" })!,
|
||||
content.files.first(where: { $0.id == "image2" })!,
|
||||
content.files.first(where: { $0.id == "image3" })!,
|
||||
content.files.first(where: { $0.id == "image4" })!
|
||||
content.files.first(where: { $0.identifier == "image1" })!,
|
||||
content.files.first(where: { $0.identifier == "image2" })!,
|
||||
content.files.first(where: { $0.identifier == "image3" })!,
|
||||
content.files.first(where: { $0.identifier == "image4" })!
|
||||
]))
|
||||
]
|
||||
}
|
||||
|
||||
static var empty: Post {
|
||||
Content.mock.posts.first(where: { $0.id == "empty" })!
|
||||
Content.mock.posts.first(where: { $0.identifier == "empty" })!
|
||||
}
|
||||
|
||||
static var hike: Post {
|
||||
Content.mock.posts.first(where: { $0.id == "hike" })!
|
||||
Content.mock.posts.first(where: { $0.identifier == "hike" })!
|
||||
}
|
||||
|
||||
static var hike2: Post {
|
||||
Content.mock.posts.first(where: { $0.id == "hike2" })!
|
||||
Content.mock.posts.first(where: { $0.identifier == "hike2" })!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ extension Tag {
|
||||
urlComponent: "elektronik",
|
||||
name: "Elektronik",
|
||||
linkPreview: .init(description: "Eine Beschreibung des Tags",
|
||||
image: content.files.first(where: { $0.id == "image2" })!),
|
||||
image: content.files.first(where: { $0.identifier == "image2" })!),
|
||||
originalUrl: "projects/electronics"
|
||||
),
|
||||
english: .init(
|
||||
@@ -22,7 +22,7 @@ extension Tag {
|
||||
name: "Electronics",
|
||||
linkPreview: .init(
|
||||
description: "Some description of the tag",
|
||||
image: content.files.first(where: { $0.id == "image1" })!),
|
||||
image: content.files.first(where: { $0.identifier == "image1" })!),
|
||||
originalUrl: "projects/electronics")
|
||||
),
|
||||
Tag(
|
||||
@@ -53,23 +53,23 @@ extension Tag {
|
||||
}
|
||||
|
||||
static var electronics: Tag {
|
||||
Content.mock.tags.first(where: { $0.id == "electronics" })!
|
||||
Content.mock.tags.first(where: { $0.identifier == "electronics" })!
|
||||
}
|
||||
|
||||
static var nature: Tag {
|
||||
Content.mock.tags.first(where: { $0.id == "nature" })!
|
||||
Content.mock.tags.first(where: { $0.identifier == "nature" })!
|
||||
}
|
||||
|
||||
static var sports: Tag {
|
||||
Content.mock.tags.first(where: { $0.id == "sports" })!
|
||||
Content.mock.tags.first(where: { $0.identifier == "sports" })!
|
||||
}
|
||||
|
||||
static var hiking: Tag {
|
||||
Content.mock.tags.first(where: { $0.id == "hiking" })!
|
||||
Content.mock.tags.first(where: { $0.identifier == "hiking" })!
|
||||
}
|
||||
|
||||
static var mountains: Tag {
|
||||
Content.mock.tags.first(where: { $0.id == "mountains" })!
|
||||
Content.mock.tags.first(where: { $0.identifier == "mountains" })!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ extension Content {
|
||||
continue
|
||||
}
|
||||
let path = file.absoluteUrl
|
||||
if !storage.copy(file: file.id, to: path) {
|
||||
if !storage.copy(file: file.identifier, to: path) {
|
||||
results.general.unsavedOutput(path, source: .general)
|
||||
}
|
||||
}
|
||||
@@ -144,26 +144,61 @@ 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? {
|
||||
pages.first { $0.id == pageId }
|
||||
pages.first { $0.identifier == pageId }
|
||||
}
|
||||
|
||||
func image(_ imageId: String) -> FileResource? {
|
||||
files.first { $0.id == imageId && $0.type.isImage }
|
||||
files.first { $0.identifier == imageId && $0.type.isImage }
|
||||
}
|
||||
|
||||
func video(_ videoId: String) -> FileResource? {
|
||||
files.first { $0.id == videoId && $0.type.isVideo }
|
||||
files.first { $0.identifier == videoId && $0.type.isVideo }
|
||||
}
|
||||
|
||||
func file(_ fileId: String) -> FileResource? {
|
||||
files.first { $0.id == fileId }
|
||||
files.first { $0.identifier == fileId }
|
||||
}
|
||||
|
||||
func tag(_ tagId: String) -> Tag? {
|
||||
tags.first { $0.id == tagId }
|
||||
tags.first { $0.identifier == tagId }
|
||||
}
|
||||
|
||||
// MARK: Generation input
|
||||
@@ -322,12 +357,12 @@ extension Content {
|
||||
let pageUrl = settings.general.url + relativePageUrl
|
||||
|
||||
guard let content = pageGenerator.generate(page: page, language: language, results: results, pageUrl: pageUrl) else {
|
||||
print("Failed to generate page \(page.id) in language \(language)")
|
||||
print("Failed to generate page \(page.identifier) in language \(language)")
|
||||
return
|
||||
}
|
||||
|
||||
guard storage.write(content, to: filePath) else {
|
||||
print("Failed to save page \(page.id)")
|
||||
print("Failed to save page \(page.identifier)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -387,7 +422,9 @@ extension Content {
|
||||
private func updateUnusedFiles() {
|
||||
let existing = storage.getAllOutputFiles()
|
||||
DispatchQueue.main.async {
|
||||
self.results.determineMissingRequiredFiles(existingFiles: existing)
|
||||
self.results.determineFiles(unusedIn: existing)
|
||||
self.results.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,16 +83,16 @@ extension Content {
|
||||
|
||||
func removeUnlinkedFiles() -> Bool {
|
||||
var success = true
|
||||
if !storage.deletePostFiles(notIn: posts.map { $0.id }) {
|
||||
if !storage.deletePostFiles(notIn: posts.map { $0.identifier }) {
|
||||
success = false
|
||||
}
|
||||
if !storage.deletePageFiles(notIn: pages.map { $0.id }) {
|
||||
if !storage.deletePageFiles(notIn: pages.map { $0.identifier }) {
|
||||
success = false
|
||||
}
|
||||
if !storage.deleteTagFiles(notIn: tags.map { $0.id }) {
|
||||
if !storage.deleteTagFiles(notIn: tags.map { $0.identifier }) {
|
||||
success = false
|
||||
}
|
||||
if !storage.deleteFileResources(notIn: files.map { $0.id }) {
|
||||
if !storage.deleteFileResources(notIn: files.map { $0.identifier }) {
|
||||
success = false
|
||||
}
|
||||
return success
|
||||
|
||||
@@ -7,19 +7,19 @@ extension Content {
|
||||
private static let disallowedCharactersInFileIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-.")).inverted
|
||||
|
||||
func isNewIdForTag(_ id: String) -> Bool {
|
||||
tagOverview?.id != id && !tags.contains { $0.id == id }
|
||||
tagOverview?.identifier != id && !tags.contains { $0.identifier == id }
|
||||
}
|
||||
|
||||
func isNewIdForPage(_ id: String) -> Bool {
|
||||
!pages.contains { $0.id == id }
|
||||
!pages.contains { $0.identifier == id }
|
||||
}
|
||||
|
||||
func isNewIdForPost(_ id: String) -> Bool {
|
||||
!posts.contains { $0.id == id }
|
||||
!posts.contains { $0.identifier == id }
|
||||
}
|
||||
|
||||
func isNewIdForFile(_ id: String) -> Bool {
|
||||
!files.contains { $0.id == id }
|
||||
!files.contains { $0.identifier == id }
|
||||
}
|
||||
|
||||
func isValidIdForTagOrPageOrPost(_ id: String) -> Bool {
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
final class Content: ObservableObject {
|
||||
final class Content: ChangeObservableItem {
|
||||
|
||||
@ObservedObject
|
||||
var storage: Storage
|
||||
@@ -47,6 +47,11 @@ final class Content: ObservableObject {
|
||||
|
||||
var errorCallback: ((StorageError) -> Void)?
|
||||
|
||||
var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
/// A cache of file sizes
|
||||
private var fileSizes: [String: Int] = [:]
|
||||
|
||||
init() {
|
||||
let settings = Settings.default
|
||||
self.settings = settings
|
||||
@@ -110,6 +115,8 @@ final class Content: ObservableObject {
|
||||
loadFromDisk(callback: callback)
|
||||
}
|
||||
|
||||
// MARK: Removing items
|
||||
|
||||
func remove(_ file: FileResource) {
|
||||
files.remove(file)
|
||||
for post in posts {
|
||||
@@ -126,6 +133,33 @@ final class Content: ObservableObject {
|
||||
settings.remove(file)
|
||||
}
|
||||
|
||||
func remove(_ post: Post) {
|
||||
posts.remove(post)
|
||||
}
|
||||
|
||||
func remove(_ page: Page) {
|
||||
pages.remove(page)
|
||||
for post in posts {
|
||||
if post.linkedPage == page {
|
||||
post.linkedPage = nil
|
||||
}
|
||||
}
|
||||
// TODO: Check for page links and other references in content
|
||||
}
|
||||
|
||||
func remove(_ tag: Tag) {
|
||||
tags.remove(tag)
|
||||
for post in posts {
|
||||
post.tags.remove(tag)
|
||||
}
|
||||
for page in pages {
|
||||
page.tags.remove(tag)
|
||||
}
|
||||
// TODO: Check for tag links and other references is content
|
||||
}
|
||||
|
||||
// MARK: Loading
|
||||
|
||||
func file(withOutputPath: String) -> FileResource? {
|
||||
files.first { $0.absoluteUrl == withOutputPath }
|
||||
}
|
||||
@@ -168,9 +202,9 @@ final class Content: ObservableObject {
|
||||
for file in self.files {
|
||||
guard file.type.isVideo else { continue }
|
||||
guard !file.isExternallyStored else { continue }
|
||||
guard !storage.hasVideoThumbnail(for: file.id) else { continue }
|
||||
if await imageGenerator.createVideoThumbnail(for: file.id) {
|
||||
print("Generated thumbnail for \(file.id)")
|
||||
guard !storage.hasVideoThumbnail(for: file.identifier) else { continue }
|
||||
if await imageGenerator.createVideoThumbnail(for: file.identifier) {
|
||||
print("Generated thumbnail for \(file.identifier)")
|
||||
file.didChange()
|
||||
}
|
||||
}
|
||||
@@ -196,4 +230,36 @@ final class Content: ObservableObject {
|
||||
func setLastSaveTimestamp() {
|
||||
self.lastSave = .now
|
||||
}
|
||||
|
||||
func needsSaving() {
|
||||
needsSave()
|
||||
}
|
||||
|
||||
// MARK: File sizes
|
||||
|
||||
func size(of file: String) -> Int? {
|
||||
fileSizes[file]
|
||||
}
|
||||
|
||||
func cache(size: Int?, of file: String) {
|
||||
fileSizes[file] = size
|
||||
}
|
||||
|
||||
// MARK: Image dimensions
|
||||
|
||||
private var imageDimensions: [String: CGSize] = [:]
|
||||
|
||||
private let imageDimensionsQueue = DispatchQueue(label: "imageDimensionsQueue")
|
||||
|
||||
func dimensions(of image: String) -> CGSize? {
|
||||
imageDimensionsQueue.sync {
|
||||
imageDimensions[image]
|
||||
}
|
||||
}
|
||||
|
||||
func cache(dimensions: CGSize?, of image: String) {
|
||||
imageDimensionsQueue.sync {
|
||||
imageDimensions[image] = dimensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
protocol DateItem {
|
||||
|
||||
var id: String { get }
|
||||
var identifier: String { get }
|
||||
|
||||
var startDate: Date { get }
|
||||
|
||||
@@ -20,7 +20,7 @@ extension Sequence where Element: DateItem {
|
||||
func sortedByStartDateAndId() -> [Element] {
|
||||
sorted { (lhs, rhs) -> Bool in
|
||||
if lhs.startDate == rhs.startDate {
|
||||
return lhs.id < rhs.id
|
||||
return lhs.identifier < rhs.identifier
|
||||
}
|
||||
return lhs.startDate > rhs.startDate
|
||||
}
|
||||
|
||||
@@ -48,12 +48,22 @@ final class FileResource: Item, LocalizedItem {
|
||||
var isAsset: Bool
|
||||
|
||||
/// The dimensions of the image
|
||||
@Published
|
||||
var imageDimensions: CGSize? = nil
|
||||
var imageDimensions: CGSize? {
|
||||
get { content.dimensions(of: identifier) }
|
||||
set {
|
||||
content.cache(dimensions: newValue, of: identifier)
|
||||
didChange(save: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// The size of the file in bytes
|
||||
@Published
|
||||
var fileSize: Int? = nil
|
||||
var fileSize: Int? {
|
||||
get { content.size(of: identifier) }
|
||||
set {
|
||||
content.cache(size: newValue, of: identifier)
|
||||
didChange(save: false)
|
||||
}
|
||||
}
|
||||
|
||||
var savedData: Data?
|
||||
|
||||
@@ -104,11 +114,20 @@ final class FileResource: Item, LocalizedItem {
|
||||
// MARK: Text
|
||||
|
||||
func textContent() -> String {
|
||||
content.storage.fileContent(for: id) ?? ""
|
||||
content.storage.fileContent(for: identifier) ?? ""
|
||||
}
|
||||
|
||||
func save(textContent: String) -> Bool {
|
||||
guard content.storage.save(fileContent: textContent, for: id) else {
|
||||
guard content.storage.save(fileContent: textContent, for: identifier) else {
|
||||
return false
|
||||
}
|
||||
modifiedDate = .now
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(fileData: Foundation.Data) -> Bool {
|
||||
guard content.storage.save(fileData: fileData, for: identifier) else {
|
||||
return false
|
||||
}
|
||||
modifiedDate = .now
|
||||
@@ -116,7 +135,7 @@ final class FileResource: Item, LocalizedItem {
|
||||
}
|
||||
|
||||
func dataContent() -> Foundation.Data? {
|
||||
content.storage.fileData(for: id)
|
||||
content.storage.fileData(for: identifier)
|
||||
}
|
||||
|
||||
// MARK: Images
|
||||
@@ -155,7 +174,7 @@ final class FileResource: Item, LocalizedItem {
|
||||
}
|
||||
update(fileSize: displayImageData.count)
|
||||
guard let loadedImage = NSImage(data: displayImageData) else {
|
||||
print("Failed to create image \(id)")
|
||||
print("Failed to create image \(identifier)")
|
||||
return nil
|
||||
}
|
||||
update(imageDimensions: loadedImage.size)
|
||||
@@ -181,14 +200,14 @@ final class FileResource: Item, LocalizedItem {
|
||||
|
||||
private var displayImageData: Foundation.Data? {
|
||||
if type.isImage {
|
||||
guard let data = content.storage.fileData(for: id) else {
|
||||
print("Failed to load data for image \(id)")
|
||||
guard let data = content.storage.fileData(for: identifier) else {
|
||||
print("Failed to load data for image \(identifier)")
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
if type.isVideo {
|
||||
return content.storage.getVideoThumbnail(for: id)
|
||||
return content.storage.getVideoThumbnail(for: identifier)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -224,7 +243,7 @@ final class FileResource: Item, LocalizedItem {
|
||||
|
||||
func determineFileSize() {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let size = self.content.storage.size(of: self.id)
|
||||
let size = self.content.storage.size(of: self.identifier)
|
||||
self.update(fileSize: size)
|
||||
}
|
||||
}
|
||||
@@ -239,7 +258,7 @@ final class FileResource: Item, LocalizedItem {
|
||||
|
||||
/// The path to the output folder where image versions are stored (no leading slash)
|
||||
var outputImageFolder: String {
|
||||
"\(content.settings.paths.imagesOutputFolderPath)/\(id.fileNameWithoutExtension)"
|
||||
"\(content.settings.paths.imagesOutputFolderPath)/\(identifier.fileNameWithoutExtension)"
|
||||
}
|
||||
|
||||
func outputPath(width: Int, height: Int, type: FileType?) -> String {
|
||||
@@ -250,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,
|
||||
@@ -279,18 +299,106 @@ 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() {
|
||||
guard type.isVideo else { return }
|
||||
guard !content.storage.hasVideoThumbnail(for: id) else { return }
|
||||
guard !content.storage.hasVideoThumbnail(for: identifier) else { return }
|
||||
Task {
|
||||
if await content.imageGenerator.createVideoThumbnail(for: id) {
|
||||
if await content.imageGenerator.createVideoThumbnail(for: identifier) {
|
||||
didChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -312,7 +420,7 @@ final class FileResource: Item, LocalizedItem {
|
||||
return "/" + customOutputPath
|
||||
}
|
||||
}
|
||||
let path = pathPrefix + "/" + id
|
||||
let path = pathPrefix + "/" + identifier
|
||||
return makeCleanAbsolutePath(path)
|
||||
}
|
||||
|
||||
@@ -343,22 +451,37 @@ final class FileResource: Item, LocalizedItem {
|
||||
@discardableResult
|
||||
func update(id newId: String) -> Bool {
|
||||
guard !isExternallyStored else {
|
||||
id = newId
|
||||
identifier = newId
|
||||
return true
|
||||
}
|
||||
guard content.storage.move(file: id, to: newId) else {
|
||||
print("Failed to move file \(id) to \(newId)")
|
||||
guard content.storage.move(file: identifier, to: newId) else {
|
||||
print("Failed to move file \(identifier) to \(newId)")
|
||||
return false
|
||||
}
|
||||
id = newId
|
||||
identifier = newId
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
id
|
||||
identifier
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +531,6 @@ extension FileResource: StorageItem {
|
||||
}
|
||||
|
||||
func saveToDisk(_ data: Data) -> Bool {
|
||||
content.storage.save(fileResource: data, for: id)
|
||||
content.storage.save(fileResource: data, for: identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ class ChangeObservingItem: ObservableContentItem {
|
||||
/// A dummy property to force views to update when properties change
|
||||
@Published
|
||||
private var changeToggle = false
|
||||
|
||||
|
||||
private var shouldSave = true
|
||||
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(content: Content) {
|
||||
@@ -19,9 +21,18 @@ class ChangeObservingItem: ObservableContentItem {
|
||||
|
||||
// MARK: Change observation
|
||||
|
||||
func didChange() {
|
||||
func didChange(save: Bool = true) {
|
||||
DispatchQueue.main.async {
|
||||
self.shouldSave = save
|
||||
self.changeToggle.toggle()
|
||||
self.shouldSave = true
|
||||
}
|
||||
}
|
||||
|
||||
func needsSaving() {
|
||||
guard shouldSave else {
|
||||
return
|
||||
}
|
||||
content.needsSave()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,17 @@ class Item: ChangeObservingItem, Identifiable {
|
||||
@Published
|
||||
private var changeToggle = false
|
||||
|
||||
/// A session-id for the item for identification
|
||||
let id = UUID()
|
||||
|
||||
/// The unique, persistent identifier of the item
|
||||
///
|
||||
/// This identifier is not used for `Identifiable`, since it may be changed through the UI.
|
||||
@Published
|
||||
var id: String
|
||||
var identifier: String
|
||||
|
||||
init(content: Content, id: String) {
|
||||
self.id = id
|
||||
self.identifier = id
|
||||
super.init(content: content)
|
||||
|
||||
observeChanges()
|
||||
@@ -44,14 +50,14 @@ class Item: ChangeObservingItem, Identifiable {
|
||||
}
|
||||
|
||||
var itemId: ItemId {
|
||||
.init(type: itemType, id: id)
|
||||
.init(type: itemType, id: identifier)
|
||||
}
|
||||
}
|
||||
|
||||
extension Item: Equatable {
|
||||
|
||||
static func == (lhs: Item, rhs: Item) -> Bool {
|
||||
lhs.id == rhs.id && lhs.itemType == rhs.itemType
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +65,12 @@ extension Item: Hashable {
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
hasher.combine(itemType)
|
||||
}
|
||||
}
|
||||
|
||||
extension Item: Comparable {
|
||||
|
||||
static func < (lhs: Item, rhs: Item) -> Bool {
|
||||
lhs.id < rhs.id && lhs.itemType < rhs.itemType
|
||||
lhs.identifier < rhs.identifier && lhs.itemType < rhs.itemType
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ extension ItemReference: Identifiable {
|
||||
case .feed:
|
||||
return "1-feed"
|
||||
case .post(let post):
|
||||
return "2-post-\(post.id)"
|
||||
return "2-post-\(post.identifier)"
|
||||
case .page(let page):
|
||||
return "3-page-\(page.id)"
|
||||
return "3-page-\(page.identifier)"
|
||||
case .tagPage(let tag):
|
||||
return "5-tag-\(tag.id)"
|
||||
return "5-tag-\(tag.identifier)"
|
||||
case .tagOverview:
|
||||
return "4-tag-overview"
|
||||
}
|
||||
@@ -76,11 +76,11 @@ extension ItemReference: CustomStringConvertible {
|
||||
case .feed:
|
||||
return "Feed"
|
||||
case .post(let post):
|
||||
return "Post \(post.id)"
|
||||
return "Post \(post.identifier)"
|
||||
case .page(let page):
|
||||
return "Page \(page.id)"
|
||||
return "Page \(page.identifier)"
|
||||
case .tagPage(let tag):
|
||||
return "Tag \(tag.id)"
|
||||
return "Tag \(tag.identifier)"
|
||||
case .tagOverview:
|
||||
return "Tag Overview"
|
||||
}
|
||||
|
||||
@@ -31,3 +31,10 @@ extension LocalizedItemId: Comparable {
|
||||
return lhs.language < rhs.language
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalizedItemId: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
"\(itemType) (\(language))"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ final class LinkPreview: ObservableObject {
|
||||
// MARK: Storage
|
||||
|
||||
var data: Data {
|
||||
.init(title: title, description: description, image: image?.id)
|
||||
.init(title: title, description: description, image: image?.identifier)
|
||||
}
|
||||
|
||||
init(context: LoadingContext, data: Data) {
|
||||
|
||||
@@ -27,7 +27,7 @@ final class LoadingContext {
|
||||
posts: posts.values.sortedByStartDateAndId(),
|
||||
pages: pages.values.sortedByStartDateAndId(),
|
||||
tags: tags.values.sorted(),
|
||||
files: files.values.sorted { $0.id },
|
||||
files: files.values.sorted { $0.identifier },
|
||||
tagOverview: tagOverview,
|
||||
errors: errors.sorted().map { StorageError(message: $0) })
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ extension LocalizedPost {
|
||||
}
|
||||
|
||||
var data: Data {
|
||||
.init(images: images.map { $0.id },
|
||||
.init(images: images.map { $0.identifier },
|
||||
labels: labels.map { $0.data }.nonEmpty,
|
||||
title: title,
|
||||
text: text,
|
||||
|
||||
@@ -75,11 +75,11 @@ final class Page: Item, DateItem, LocalizedItem {
|
||||
|
||||
@discardableResult
|
||||
func update(id newId: String) -> Bool {
|
||||
guard content.storage.move(page: id, to: newId) else {
|
||||
print("Failed to move files of page \(id)")
|
||||
guard content.storage.move(page: identifier, to: newId) else {
|
||||
print("Failed to move files of page \(identifier)")
|
||||
return false
|
||||
}
|
||||
id = newId
|
||||
identifier = newId
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -146,11 +146,11 @@ final class Page: Item, DateItem, LocalizedItem {
|
||||
}
|
||||
|
||||
func pageContent(in language: ContentLanguage) -> String? {
|
||||
content.storage.pageContent(for: id, language: language)
|
||||
content.storage.pageContent(for: identifier, language: language)
|
||||
}
|
||||
|
||||
func removeContent(in language: ContentLanguage) -> Bool {
|
||||
guard content.storage.remove(pageContent: id, in: language) else {
|
||||
guard content.storage.remove(pageContent: identifier, in: language) else {
|
||||
return false
|
||||
}
|
||||
if localized(in: language).update(hasContent: false) {
|
||||
@@ -160,7 +160,7 @@ final class Page: Item, DateItem, LocalizedItem {
|
||||
}
|
||||
|
||||
func save(pageContent: String, in language: ContentLanguage) -> Bool {
|
||||
guard content.storage.save(pageContent: pageContent, for: id, in: language) else {
|
||||
guard content.storage.save(pageContent: pageContent, for: identifier, in: language) else {
|
||||
return false
|
||||
}
|
||||
if localized(in: language).update(hasContent: true) {
|
||||
@@ -175,7 +175,7 @@ final class Page: Item, DateItem, LocalizedItem {
|
||||
func updateContentExistence() {
|
||||
var didUpdate = false
|
||||
for language in ContentLanguage.allCases {
|
||||
let hasContent = content.storage.hasPageContent(for: id, language: language)
|
||||
let hasContent = content.storage.hasPageContent(for: identifier, language: language)
|
||||
if localized(in: language).update(hasContent: hasContent) {
|
||||
didUpdate = true
|
||||
}
|
||||
@@ -234,7 +234,7 @@ extension Page: StorageItem {
|
||||
.init(
|
||||
isDraft: isDraft,
|
||||
externalLink: externalLink,
|
||||
tags: tags.map { $0.id },
|
||||
tags: tags.map { $0.identifier },
|
||||
hideDate: hideDate ? true : nil,
|
||||
createdDate: createdDate,
|
||||
startDate: startDate,
|
||||
@@ -244,6 +244,6 @@ extension Page: StorageItem {
|
||||
}
|
||||
|
||||
func saveToDisk(_ data: Data) -> Bool {
|
||||
content.storage.save(page: data, for: id)
|
||||
content.storage.save(page: data, for: identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -118,11 +124,11 @@ final class Post: Item, DateItem, LocalizedItem {
|
||||
A title for the UI, not the generation.
|
||||
*/
|
||||
override func title(in language: ContentLanguage) -> String {
|
||||
localized(in: language).title ?? id
|
||||
localized(in: language).title ?? identifier
|
||||
}
|
||||
|
||||
func contains(_ string: String) -> Bool {
|
||||
id.contains(string) ||
|
||||
identifier.contains(string) ||
|
||||
german.contains(string) ||
|
||||
english.contains(string)
|
||||
}
|
||||
@@ -135,11 +141,11 @@ final class Post: Item, DateItem, LocalizedItem {
|
||||
|
||||
@discardableResult
|
||||
func update(id newId: String) -> Bool {
|
||||
guard content.storage.move(post: id, to: newId) else {
|
||||
print("Failed to move file of post \(id)")
|
||||
guard content.storage.move(post: identifier, to: newId) else {
|
||||
print("Failed to move file of post \(identifier)")
|
||||
return false
|
||||
}
|
||||
id = newId
|
||||
identifier = newId
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -149,10 +155,10 @@ final class Post: Item, DateItem, LocalizedItem {
|
||||
}
|
||||
|
||||
func makePage() -> Page {
|
||||
var id = self.id
|
||||
var id = self.identifier
|
||||
var number = 2
|
||||
while !content.isNewIdForPage(id) {
|
||||
id += "\(self.id)-\(number)"
|
||||
id += "\(self.identifier)-\(number)"
|
||||
number += 1
|
||||
}
|
||||
// Move tags to page
|
||||
@@ -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 {
|
||||
@@ -210,13 +228,14 @@ extension Post: StorageItem {
|
||||
createdDate: createdDate,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
tags: tags.map { $0.id },
|
||||
tags: tags.map { $0.identifier },
|
||||
german: german.data,
|
||||
english: english.data,
|
||||
linkedPageId: linkedPage?.id)
|
||||
linkedPageId: linkedPage?.identifier,
|
||||
associatedWorkoutIds: associatedWorkouts.map { $0.identifier}.nonEmpty )
|
||||
}
|
||||
|
||||
func saveToDisk(_ data: Data) -> Bool {
|
||||
content.storage.save(post: data, for: id)
|
||||
content.storage.save(post: data, for: identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ extension AudioPlayerSettings {
|
||||
var data: Data {
|
||||
.init(playlistCoverImageSize: playlistCoverImageSize,
|
||||
smallCoverImageSize: smallCoverImageSize,
|
||||
audioPlayerJsFile: audioPlayerJsFile?.id,
|
||||
audioPlayerCssFile: audioPlayerCssFile?.id,
|
||||
audioPlayerJsFile: audioPlayerJsFile?.identifier,
|
||||
audioPlayerCssFile: audioPlayerCssFile?.identifier,
|
||||
german: german.data,
|
||||
english: english.data)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ extension GeneralSettings {
|
||||
remotePortForUpload: remotePortForUpload,
|
||||
remotePathForUpload: remotePathForUpload,
|
||||
urlForPushNotification: urlForPushNotification,
|
||||
requiredFiles: requiredFiles.nonEmpty?.map { $0.id }.sorted())
|
||||
requiredFiles: requiredFiles.nonEmpty?.map { $0.identifier }.sorted())
|
||||
}
|
||||
|
||||
struct Data: Codable, Equatable {
|
||||
|
||||
@@ -113,13 +113,13 @@ extension PageSettings {
|
||||
.init(contentWidth: contentWidth,
|
||||
largeImageWidth: largeImageWidth,
|
||||
pageLinkImageSize: pageLinkImageSize,
|
||||
defaultCssFile: defaultCssFile?.id,
|
||||
codeHighlightingJsFile: codeHighlightingJsFile?.id,
|
||||
modelViewerJsFile: modelViewerJsFile?.id,
|
||||
imageCompareJsFile: imageCompareJsFile?.id,
|
||||
imageCompareCssFile: imageCompareCssFile?.id,
|
||||
manifestFile: manifestFile?.id,
|
||||
routeJsFile: routeJsFile?.id,
|
||||
defaultCssFile: defaultCssFile?.identifier,
|
||||
codeHighlightingJsFile: codeHighlightingJsFile?.identifier,
|
||||
modelViewerJsFile: modelViewerJsFile?.identifier,
|
||||
imageCompareJsFile: imageCompareJsFile?.identifier,
|
||||
imageCompareCssFile: imageCompareCssFile?.identifier,
|
||||
manifestFile: manifestFile?.identifier,
|
||||
routeJsFile: routeJsFile?.identifier,
|
||||
german: german.data,
|
||||
english: english.data)
|
||||
}
|
||||
|
||||
@@ -72,9 +72,9 @@ extension PostSettings {
|
||||
var data: PostSettings.Data {
|
||||
.init(postsPerPage: postsPerPage,
|
||||
contentWidth: contentWidth,
|
||||
swiperCssFile: swiperCssFile?.id,
|
||||
swiperJsFile: swiperJsFile?.id,
|
||||
defaultCssFile: defaultCssFile?.id,
|
||||
swiperCssFile: swiperCssFile?.identifier,
|
||||
swiperJsFile: swiperJsFile?.identifier,
|
||||
defaultCssFile: defaultCssFile?.identifier,
|
||||
german: german.data,
|
||||
english: english.data)
|
||||
}
|
||||
|
||||
@@ -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,16 @@ 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()
|
||||
}
|
||||
|
||||
func remove(_ file: FileResource) {
|
||||
@@ -49,6 +55,16 @@ final class Settings: ChangeObservableItem {
|
||||
func needsSaving() {
|
||||
content?.needsSave()
|
||||
}
|
||||
|
||||
private func observeChildChanges() {
|
||||
observe(general)
|
||||
observe(paths)
|
||||
observe(navigation)
|
||||
observe(posts)
|
||||
observe(pages)
|
||||
observe(audioPlayer)
|
||||
observe(tools)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Storage
|
||||
@@ -62,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
|
||||
}
|
||||
|
||||
@@ -74,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 {
|
||||
@@ -85,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 {
|
||||
@@ -100,7 +119,8 @@ extension Settings {
|
||||
navigation: .default,
|
||||
posts: .default,
|
||||
pages: .default,
|
||||
audioPlayer: .default)
|
||||
audioPlayer: .default,
|
||||
tools: .default)
|
||||
}
|
||||
|
||||
extension GeneralSettings {
|
||||
@@ -185,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)
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,11 @@ class Tag: Item, LocalizedItem {
|
||||
|
||||
@discardableResult
|
||||
func update(id newId: String) -> Bool {
|
||||
guard content.storage.move(tag: id, to: newId) else {
|
||||
print("Failed to move files of tag \(id)")
|
||||
guard content.storage.move(tag: identifier, to: newId) else {
|
||||
print("Failed to move files of tag \(identifier)")
|
||||
return false
|
||||
}
|
||||
id = newId
|
||||
identifier = newId
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -106,6 +106,6 @@ extension Tag: StorageItem {
|
||||
}
|
||||
|
||||
func saveToDisk(_ data: Data) -> Bool {
|
||||
content.storage.save(tag: data, for: id)
|
||||
content.storage.save(tag: data, for: identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
final class TagOverview: Tag {
|
||||
|
||||
override var itemId: ItemId {
|
||||
.init(type: .tagOverview, id: id)
|
||||
.init(type: .tagOverview, id: identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,25 @@ protocol ChangeObservableItem: ObservableObject {
|
||||
func needsSaving()
|
||||
}
|
||||
|
||||
extension ChangeObservableItem {
|
||||
|
||||
func observeChanges() {
|
||||
objectWillChange
|
||||
.sink { [weak self] _ in
|
||||
self?.needsSaving()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func observe<T>(_ object: T) where T: ObservableObject {
|
||||
object.objectWillChange
|
||||
.sink { [weak self] _ in
|
||||
self?.needsSaving()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
protocol ObservableContentItem: ChangeObservableItem {
|
||||
|
||||
var content: Content { get }
|
||||
@@ -18,14 +37,3 @@ extension ObservableContentItem {
|
||||
content.needsSave()
|
||||
}
|
||||
}
|
||||
|
||||
extension ChangeObservableItem {
|
||||
|
||||
func observeChanges() {
|
||||
objectWillChange
|
||||
.sink { [weak self] _ in
|
||||
self?.needsSaving()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,27 @@ final class Storage: ObservableObject {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
Completely delete a post file from the content folder
|
||||
*/
|
||||
func delete(page pageId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
guard contentScope.deleteFile(at: pageMetadataPath(page: pageId)) else {
|
||||
return false
|
||||
}
|
||||
// Move the existing content files
|
||||
var result = true
|
||||
for language in ContentLanguage.allCases {
|
||||
// Copy as many files as possible, since metadata was already moved
|
||||
// Don't fail early
|
||||
if !contentScope.deleteFile(at: pageContentPath(page: pageId, language: language)) {
|
||||
print("Failed to delete content file \(language) of page \(pageId)")
|
||||
result = false
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: Posts
|
||||
|
||||
private func postFileName(_ postId: String) -> String {
|
||||
@@ -186,6 +207,14 @@ final class Storage: ObservableObject {
|
||||
return contentScope.move(postFilePath(post: postId), to: postFilePath(post: newId))
|
||||
}
|
||||
|
||||
/**
|
||||
Completely delete a post file from the content folder
|
||||
*/
|
||||
func delete(post postId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
return contentScope.deleteFile(at: postFilePath(post: postId))
|
||||
}
|
||||
|
||||
// MARK: Tags
|
||||
|
||||
private func tagFileName(tagId: String) -> String {
|
||||
@@ -225,6 +254,11 @@ final class Storage: ObservableObject {
|
||||
return contentScope.move(tagFilePath(tag: tagId), to: tagFilePath(tag: newId))
|
||||
}
|
||||
|
||||
func delete(tag tagId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
return contentScope.deleteFile(at: tagFilePath(tag: tagId))
|
||||
}
|
||||
|
||||
// MARK: Files
|
||||
|
||||
func size(of file: String) -> Int? {
|
||||
@@ -380,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)
|
||||
@@ -392,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 {
|
||||
|
||||
@@ -78,6 +78,10 @@ struct AddFileView: View {
|
||||
}
|
||||
|
||||
private func importSelectedFiles() {
|
||||
guard !filesToAdd.isEmpty else {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
for file in filesToAdd {
|
||||
guard file.isSelected else {
|
||||
print("Skipping unselected file \(file.uniqueId)")
|
||||
@@ -104,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()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ struct FileContentView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
case .text, .code:
|
||||
TextFileContentView(file: file)
|
||||
.id(file.id + file.modifiedDate.description)
|
||||
.id(file.identifier + file.modifiedDate.description)
|
||||
case .video:
|
||||
VStack {
|
||||
if let image = file.imageToDisplay {
|
||||
@@ -77,6 +77,9 @@ struct FileContentView: View {
|
||||
.font(.title)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
case .route:
|
||||
RoutePreviewView(file: file)
|
||||
}
|
||||
}
|
||||
}.padding()
|
||||
|
||||
@@ -34,6 +34,9 @@ struct FileDetailView: View {
|
||||
@EnvironmentObject
|
||||
private var content: Content
|
||||
|
||||
@EnvironmentObject
|
||||
private var selection: SelectedContent
|
||||
|
||||
@Environment(\.language)
|
||||
private var language
|
||||
|
||||
@@ -48,28 +51,33 @@ struct FileDetailView: View {
|
||||
text: "A file that can be used in a post or page")
|
||||
|
||||
GenericPropertyView(title: "Actions") {
|
||||
HStack(spacing: 10) {
|
||||
ButtonIcon(.folder, action: showFileInFinder)
|
||||
ButtonIcon(.arrowClockwise, action: markFileAsChanged)
|
||||
if file.isExternallyStored {
|
||||
ButtonIcon(.squareAndArrowDown, action: replaceFile)
|
||||
} else {
|
||||
ButtonIcon(.arrowLeftArrowRight, action: replaceFile)
|
||||
ButtonIcon(.squareDashed, action: convertToExternal)
|
||||
}
|
||||
ButtonIcon(.trash, action: deleteFile)
|
||||
.foregroundStyle(.red)
|
||||
if file.isExternallyStored {
|
||||
ColoredButton(
|
||||
icon: .squareAndArrowDown,
|
||||
text: "Convert to internal file",
|
||||
action: replaceFile)
|
||||
} else {
|
||||
ColoredButton(icon: .folder, text: "Show in folder", action: showFileInFinder)
|
||||
ColoredButton(icon: .arrowClockwise, text: "Mark file as changed", action: markFileAsChanged)
|
||||
ColoredButton(
|
||||
icon: .arrowLeftArrowRight,
|
||||
text: "Replace file",
|
||||
action: replaceFile)
|
||||
ColoredButton(
|
||||
icon: .squareDashed,
|
||||
text: "Convert to external file",
|
||||
action: convertToExternal)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.blue)
|
||||
ColoredButton(delete: deleteFile)
|
||||
}
|
||||
|
||||
IdPropertyView(
|
||||
id: $file.id,
|
||||
id: $file.identifier,
|
||||
title: "Name",
|
||||
footer: "The unique name of the file, which is also used to reference it in posts and pages.",
|
||||
validation: file.isValid,
|
||||
update: { file.update(id: $0) })
|
||||
.id(file.id)
|
||||
|
||||
switch language {
|
||||
case .english:
|
||||
@@ -147,7 +155,7 @@ struct FileDetailView: View {
|
||||
}
|
||||
|
||||
private func showFileInFinder() {
|
||||
content.storage.openFinderWindow(withSelectedFile: file.id)
|
||||
content.storage.openFinderWindow(withSelectedFile: file.identifier)
|
||||
}
|
||||
|
||||
private func markFileAsChanged() {
|
||||
@@ -162,17 +170,18 @@ struct FileDetailView: View {
|
||||
|
||||
private func replaceFile() {
|
||||
guard let url = openFilePanel() else {
|
||||
print("File '\(file.id)': No file selected as replacement")
|
||||
print("File '\(file.identifier)': No file selected as replacement")
|
||||
return
|
||||
}
|
||||
guard content.storage.importExternalFile(at: url, fileId: file.id) else {
|
||||
print("File '\(file.id)': Failed to replace file")
|
||||
guard content.storage.importExternalFile(at: url, fileId: file.identifier) else {
|
||||
print("File '\(file.identifier)': Failed to replace file")
|
||||
return
|
||||
}
|
||||
|
||||
markFileAsChanged()
|
||||
if file.isExternallyStored {
|
||||
DispatchQueue.main.async {
|
||||
// This will also trigger a save
|
||||
file.isExternallyStored = false
|
||||
}
|
||||
}
|
||||
@@ -190,7 +199,7 @@ struct FileDetailView: View {
|
||||
|
||||
let response = panel.runModal()
|
||||
guard response == .OK else {
|
||||
print("File '\(file.id)': Failed to select file to replace")
|
||||
print("File '\(file.identifier)': Failed to select file to replace")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -202,22 +211,25 @@ struct FileDetailView: View {
|
||||
return
|
||||
}
|
||||
|
||||
guard content.storage.removeFileContent(file: file.id) else {
|
||||
print("File '\(file.id)': Failed to delete file to make it external")
|
||||
guard content.storage.removeFileContent(file: file.identifier) else {
|
||||
print("File '\(file.identifier)': Failed to delete file to make it external")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
// This will also trigger a save
|
||||
file.fileSize = nil
|
||||
file.isExternallyStored = true
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteFile() {
|
||||
guard content.storage.delete(file: file.id) else {
|
||||
print("File '\(file.id)': Failed to delete file in content folder")
|
||||
guard content.storage.delete(file: file.identifier) else {
|
||||
print("File '\(file.identifier)': Failed to delete file in content folder")
|
||||
return
|
||||
}
|
||||
// This will also trigger a save
|
||||
content.remove(file)
|
||||
selection.remove(file)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ struct FileListView: View {
|
||||
guard !searchString.isEmpty else {
|
||||
return filesBySelectedType
|
||||
}
|
||||
return filesBySelectedType.filter { $0.id.contains(searchString) }
|
||||
return filesBySelectedType.filter { $0.identifier.contains(searchString) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -55,10 +55,10 @@ struct FileListView: View {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(filteredFiles) { file in
|
||||
SelectableListItem(selected: selectedFile == file) {
|
||||
Text(file.id)
|
||||
Text(file.identifier)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.id(file.id)
|
||||
.id(file.identifier)
|
||||
.onTapGesture {
|
||||
selectedFile = file
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ final class FileToAdd: ObservableObject {
|
||||
}
|
||||
|
||||
var idAlreadyExists: Bool {
|
||||
content.files.contains { $0.id == uniqueId }
|
||||
content.files.contains { $0.identifier == uniqueId }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ struct MultiFileSelectionView: View {
|
||||
guard !searchString.isEmpty else {
|
||||
return filesBySelectedType
|
||||
}
|
||||
return filesBySelectedType.filter { $0.id.contains(searchString) }
|
||||
return filesBySelectedType.filter { $0.identifier.contains(searchString) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -59,7 +59,7 @@ struct MultiFileSelectionView: View {
|
||||
.foregroundStyle(.red)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { deselect(file: file) }
|
||||
Text(file.id)
|
||||
Text(file.identifier)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ struct MultiFileSelectionView: View {
|
||||
Image(systemSymbol: .plusCircleFill)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Text(file.id)
|
||||
Text(file.identifier)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
|
||||
@@ -49,9 +49,9 @@ struct TextFileContentView: View {
|
||||
|
||||
private func reload() {
|
||||
fileContent = file.textContent()
|
||||
loadedFile = file.id
|
||||
loadedFile = file.identifier
|
||||
loadedFileDate = file.modifiedDate
|
||||
print("Loaded content of file \(file.id)")
|
||||
print("Loaded content of file \(file.identifier)")
|
||||
}
|
||||
|
||||
private func save() {
|
||||
@@ -59,25 +59,25 @@ struct TextFileContentView: View {
|
||||
print("[ERROR] Text File View: No file loaded to save")
|
||||
return
|
||||
}
|
||||
guard loadedFile == file.id else {
|
||||
guard loadedFile == file.identifier else {
|
||||
print("[ERROR] Text File View: Not saving since file changed")
|
||||
reload()
|
||||
return
|
||||
}
|
||||
guard loadedFileDate == file.modifiedDate else {
|
||||
print("Text File View: Not saving changed file \(file.id)")
|
||||
print("Text File View: Not saving changed file \(file.identifier)")
|
||||
reload()
|
||||
return
|
||||
}
|
||||
guard fileContent != "" else {
|
||||
print("Text File View: Not saving empty file \(file.id)")
|
||||
print("Text File View: Not saving empty file \(file.identifier)")
|
||||
return
|
||||
}
|
||||
guard file.save(textContent: fileContent) else {
|
||||
print("[ERROR] Text File View: Failed to save file \(file.id)")
|
||||
print("[ERROR] Text File View: Failed to save file \(file.identifier)")
|
||||
return
|
||||
}
|
||||
loadedFileDate = file.modifiedDate
|
||||
print("Text File View: Saved file \(file.id)")
|
||||
print("Text File View: Saved file \(file.identifier)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,20 @@ struct GenerationContentView: View {
|
||||
@EnvironmentObject
|
||||
private var content: Content
|
||||
|
||||
@EnvironmentObject
|
||||
private var selection: SelectedContent
|
||||
|
||||
@Environment(\.dismiss)
|
||||
private var dismiss
|
||||
|
||||
var draftPages: Set<Page> {
|
||||
Set(content.pages.filter { $0.isDraft })
|
||||
}
|
||||
|
||||
var draftPosts: Set<Post> {
|
||||
Set(content.posts.filter { $0.isDraft })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Website Generation")
|
||||
@@ -45,7 +56,7 @@ struct GenerationContentView: View {
|
||||
GenerationStringIssuesView(
|
||||
text: "output files",
|
||||
statusWhenNonEmpty: .nominal,
|
||||
items: $content.results.outputFiles)
|
||||
items: content.results.outputFiles)
|
||||
GenerationResultsIssueView(
|
||||
text: "\(content.results.imagesToGenerate.count) images",
|
||||
status: .nominal,
|
||||
@@ -57,54 +68,94 @@ struct GenerationContentView: View {
|
||||
GenerationStringIssuesView(
|
||||
text: "external links",
|
||||
statusWhenNonEmpty: .nominal,
|
||||
items: $content.results.externalLinks)
|
||||
items: content.results.externalLinks)
|
||||
GenerationStringIssuesView(
|
||||
text: "required files",
|
||||
statusWhenNonEmpty: .nominal,
|
||||
items: $content.results.requiredFiles) { $0.id }
|
||||
items: content.results.requiredFiles) { $0.identifier }
|
||||
GenerationStringIssuesView(
|
||||
text: "external files",
|
||||
statusWhenNonEmpty: .nominal,
|
||||
items: $content.results.externalFiles) { $0.id }
|
||||
GenerationStringIssuesView(
|
||||
items: content.results.externalFiles) { $0.identifier }
|
||||
GenerationIssuesView(
|
||||
text: "empty pages",
|
||||
statusWhenNonEmpty: .warning,
|
||||
items: $content.results.emptyPages) { "\($0.pageId) (\($0.language))" }
|
||||
GenerationStringIssuesView(
|
||||
items: $content.results.emptyPages) { pageId in
|
||||
HStack {
|
||||
Text("\(pageId.pageId) (\(pageId.language.description))")
|
||||
Spacer()
|
||||
Button("Show") {
|
||||
show(page: pageId.pageId,
|
||||
language: pageId.language)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GenerationIssuesActionView(
|
||||
title: "draft pages",
|
||||
statusWhenNonEmpty: .warning,
|
||||
items: draftPages,
|
||||
buttonText: "Show",
|
||||
itemText: { $0.identifier },
|
||||
action: { show($0) })
|
||||
GenerationIssuesActionView(
|
||||
title: "draft posts",
|
||||
statusWhenNonEmpty: .warning,
|
||||
items: draftPosts,
|
||||
buttonText: "Show",
|
||||
itemText: { $0.identifier },
|
||||
action: { show($0) })
|
||||
GenerationIssuesView(
|
||||
text: "additional output files",
|
||||
statusWhenNonEmpty: .warning,
|
||||
items: $content.results.unusedFilesInOutput)
|
||||
items: $content.results.unusedFilesInOutput) { filePath in
|
||||
HStack {
|
||||
Text(filePath)
|
||||
Spacer()
|
||||
Button("Delete", action: { delete(unusedFile: filePath) })
|
||||
}
|
||||
}
|
||||
GenerationStringIssuesView(
|
||||
text: "missing output files",
|
||||
statusWhenNonEmpty: .warning,
|
||||
items: content.results.requiredOutputFiles)
|
||||
GenerationStringIssuesView(
|
||||
text: "inaccessible files",
|
||||
items: $content.results.inaccessibleFiles) { $0.id }
|
||||
items: content.results.inaccessibleFiles) { $0.identifier }
|
||||
GenerationStringIssuesView(
|
||||
text: "unparsable files",
|
||||
items: $content.results.unparsableFiles) { $0.id }
|
||||
items: content.results.unparsableFiles) { $0.identifier }
|
||||
GenerationStringIssuesView(
|
||||
text: "unsaved output files",
|
||||
items: $content.results.unsavedOutputFiles)
|
||||
items: content.results.unsavedOutputFiles)
|
||||
GenerationStringIssuesView(
|
||||
text: "failed image generations",
|
||||
items: $content.results.failedImages) { $0.outputPath }
|
||||
items: content.results.failedImages) { $0.outputPath }
|
||||
GenerationStringIssuesView(
|
||||
text: "missing files",
|
||||
items: $content.results.missingFiles)
|
||||
items: content.results.missingFiles)
|
||||
GenerationStringIssuesView(
|
||||
text: "missing tags",
|
||||
items: $content.results.missingTags)
|
||||
items: content.results.missingTags)
|
||||
GenerationStringIssuesView(
|
||||
text: "missing pages",
|
||||
items: $content.results.missingPages)
|
||||
items: content.results.missingPages) { pageId in
|
||||
let sources = content.results.sources(forMissingPage: pageId)
|
||||
.map { "\($0.page): \($0.source)"}
|
||||
.joined(separator: ", ")
|
||||
|
||||
return "\(pageId) (\(sources))"
|
||||
}
|
||||
GenerationStringIssuesView(
|
||||
text: "invalid commands",
|
||||
items: $content.results.invalidCommands)
|
||||
items: content.results.invalidCommands)
|
||||
GenerationStringIssuesView(
|
||||
text: "invalid blocks",
|
||||
items: $content.results.invalidBlocks)
|
||||
items: content.results.invalidBlocks)
|
||||
GenerationStringIssuesView(
|
||||
text: "warnings",
|
||||
statusWhenNonEmpty: .warning,
|
||||
items: $content.results.warnings)
|
||||
items: content.results.warnings)
|
||||
HorizontalCenter {
|
||||
Button(action: { dismiss() }) {
|
||||
Text("Close")
|
||||
@@ -112,6 +163,38 @@ struct GenerationContentView: View {
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
|
||||
private func delete(unusedFile: String) {
|
||||
guard content.storage.deleteInOutputFolder(unusedFile) else {
|
||||
return
|
||||
}
|
||||
content.results.removeUnusedFile(unusedFile)
|
||||
}
|
||||
|
||||
private func show(page pageId: String, language: ContentLanguage? = nil) {
|
||||
guard let page = content.page(pageId) else {
|
||||
return
|
||||
}
|
||||
show(page, language: language)
|
||||
}
|
||||
|
||||
private func show(_ page: Page, language: ContentLanguage? = nil) {
|
||||
selection.page = page
|
||||
if let language {
|
||||
selection.language = language
|
||||
}
|
||||
selection.tab = .pages
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func show(_ post: Post, language: ContentLanguage? = nil) {
|
||||
selection.post = post
|
||||
if let language {
|
||||
selection.language = language
|
||||
}
|
||||
selection.tab = .posts
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GenerationIssuesActionView<S, T>: View where S: Collection, T: Hashable & Comparable, S.Element == T {
|
||||
|
||||
let title: String
|
||||
|
||||
let statusWhenNonEmpty: IssueStatus
|
||||
|
||||
let items: S
|
||||
|
||||
let buttonText: String
|
||||
|
||||
let itemText: (T) -> String
|
||||
|
||||
let action: (T) -> Void
|
||||
|
||||
@State
|
||||
private var showList = false
|
||||
|
||||
var status: IssueStatus {
|
||||
items.isEmpty ? .nominal : statusWhenNonEmpty
|
||||
}
|
||||
|
||||
init(title: String, statusWhenNonEmpty: IssueStatus = .error, items: S, buttonText: String, itemText: @escaping (T) -> String, action: @escaping (T) -> Void) {
|
||||
self.title = title
|
||||
self.statusWhenNonEmpty = statusWhenNonEmpty
|
||||
self.items = items
|
||||
self.buttonText = buttonText
|
||||
self.itemText = itemText
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Button(action: showListIfNonEmpty) {
|
||||
Image(systemSymbol: status.symbol)
|
||||
.foregroundStyle(status.color)
|
||||
}.buttonStyle(.plain)
|
||||
Text("\(items.count) \(title)")
|
||||
}
|
||||
.sheet(isPresented: $showList) {
|
||||
VStack {
|
||||
Text("\(items.count) \(title)")
|
||||
.font(.title)
|
||||
List(items.sorted(), id: \.self) { item in
|
||||
HStack {
|
||||
Text(itemText(item))
|
||||
Spacer()
|
||||
Button(buttonText) { action(item) }
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 400)
|
||||
Button("Close") { showList = false }
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func showListIfNonEmpty() {
|
||||
guard !items.isEmpty else {
|
||||
return
|
||||
}
|
||||
showList = true
|
||||
}
|
||||
}
|
||||
|
||||
extension GenerationIssuesActionView where S == Set<String> {
|
||||
|
||||
init(title: String, statusWhenNonEmpty: IssueStatus = .error, items: Set<String>, buttonText: String, action: @escaping (T) -> Void) {
|
||||
self.title = title
|
||||
self.statusWhenNonEmpty = statusWhenNonEmpty
|
||||
self.items = items
|
||||
self.buttonText = buttonText
|
||||
self.itemText = { $0 }
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
65
CHDataManagement/Views/Generation/GenerationIssuesView.swift
Normal file
65
CHDataManagement/Views/Generation/GenerationIssuesView.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GenerationIssuesView<S, T, V>: View where S: Collection, T: Hashable & Comparable, V: View, S.Element == T {
|
||||
|
||||
let text: String
|
||||
|
||||
let statusWhenNonEmpty: IssueStatus
|
||||
|
||||
@Binding
|
||||
var items: S
|
||||
|
||||
let map: (T) -> V
|
||||
|
||||
@State
|
||||
private var showList = false
|
||||
|
||||
var status: IssueStatus {
|
||||
items.isEmpty ? .nominal : statusWhenNonEmpty
|
||||
}
|
||||
|
||||
init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Binding<S>, map: @escaping (T) -> V) {
|
||||
self.text = text
|
||||
self.statusWhenNonEmpty = statusWhenNonEmpty
|
||||
self._items = items
|
||||
self.map = map
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Button(action: showListIfNonEmpty) {
|
||||
Image(systemSymbol: status.symbol)
|
||||
.foregroundStyle(status.color)
|
||||
}.buttonStyle(.plain)
|
||||
Text("\(items.count) \(text)")
|
||||
}
|
||||
.sheet(isPresented: $showList) {
|
||||
VStack {
|
||||
Text("\(items.count) \(text)")
|
||||
.font(.title)
|
||||
List(items.sorted(), id: \.self) { item in
|
||||
map(item)
|
||||
}
|
||||
.frame(minHeight: 400)
|
||||
Button("Close") { showList = false }
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func showListIfNonEmpty() {
|
||||
guard !items.isEmpty else {
|
||||
return
|
||||
}
|
||||
showList = true
|
||||
}
|
||||
}
|
||||
|
||||
extension GenerationIssuesView where S == Set<String>, V == Text {
|
||||
|
||||
init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Binding<Set<String>>) {
|
||||
self.text = text
|
||||
self.statusWhenNonEmpty = statusWhenNonEmpty
|
||||
self._items = items
|
||||
self.map = { Text($0) }
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,7 @@ struct GenerationStringIssuesView<T>: View where T: Hashable {
|
||||
|
||||
let statusWhenNonEmpty: IssueStatus
|
||||
|
||||
@Binding
|
||||
var items: Set<T>
|
||||
let items: Set<T>
|
||||
|
||||
let map: (T) -> String
|
||||
|
||||
@@ -18,10 +17,10 @@ struct GenerationStringIssuesView<T>: View where T: Hashable {
|
||||
items.isEmpty ? .nominal : statusWhenNonEmpty
|
||||
}
|
||||
|
||||
init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Binding<Set<T>>, map: @escaping (T) -> String) {
|
||||
init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Set<T>, map: @escaping (T) -> String) {
|
||||
self.text = text
|
||||
self.statusWhenNonEmpty = statusWhenNonEmpty
|
||||
self._items = items
|
||||
self.items = items
|
||||
self.map = map
|
||||
}
|
||||
|
||||
@@ -56,10 +55,10 @@ struct GenerationStringIssuesView<T>: View where T: Hashable {
|
||||
|
||||
extension GenerationStringIssuesView where T == String {
|
||||
|
||||
init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Binding<Set<String>>) {
|
||||
init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Set<String>) {
|
||||
self.text = text
|
||||
self.statusWhenNonEmpty = statusWhenNonEmpty
|
||||
self._items = items
|
||||
self.items = items
|
||||
self.map = { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUICore
|
||||
import SwiftUI
|
||||
|
||||
enum IssueStatus {
|
||||
case nominal
|
||||
|
||||
45
CHDataManagement/Views/Generic/ColoredButton.swift
Normal file
45
CHDataManagement/Views/Generic/ColoredButton.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
struct ColoredButton: View {
|
||||
|
||||
let icon: SFSymbol
|
||||
|
||||
let text: LocalizedStringKey
|
||||
|
||||
let fillColor: Color
|
||||
|
||||
let textColor: Color
|
||||
|
||||
let action: () -> Void
|
||||
|
||||
init(icon: SFSymbol, text: LocalizedStringKey, fillColor: Color = .blue, textColor: Color = .white, action: @escaping () -> Void) {
|
||||
self.icon = icon
|
||||
self.text = text
|
||||
self.fillColor = fillColor
|
||||
self.textColor = textColor
|
||||
self.action = action
|
||||
}
|
||||
|
||||
init(delete: @escaping () -> Void) {
|
||||
self.icon = .trash
|
||||
self.text = "Delete"
|
||||
self.fillColor = .red
|
||||
self.textColor = .white
|
||||
self.action = delete
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Image(systemSymbol: icon)
|
||||
Text(text)
|
||||
.padding(.vertical, 8)
|
||||
Spacer()
|
||||
}
|
||||
.foregroundStyle(textColor)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(fillColor))
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ struct FilePropertyView: View {
|
||||
var body: some View {
|
||||
GenericPropertyView(title: title, footer: footer) {
|
||||
HStack {
|
||||
Text(selectedFile?.id ?? "No file selected")
|
||||
Text(selectedFile?.identifier ?? "No file selected")
|
||||
Spacer()
|
||||
Button("Select") {
|
||||
showFileSelectionSheet = true
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ struct OptionalImagePropertyView: View {
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(selectedImage?.id ?? "No file selected")
|
||||
Text(selectedImage?.identifier ?? "No file selected")
|
||||
Spacer()
|
||||
Button("Select") {
|
||||
showSelectionSheet = true
|
||||
|
||||
@@ -15,7 +15,7 @@ struct PagePropertyView: View {
|
||||
var body: some View {
|
||||
GenericPropertyView(title: title, footer: footer) {
|
||||
HStack {
|
||||
Text(selectedPage?.id ?? "No page selected")
|
||||
Text(selectedPage?.identifier ?? "No page selected")
|
||||
Spacer()
|
||||
Button("Select") {
|
||||
showPageSelectionSheet = true
|
||||
|
||||
@@ -16,9 +16,9 @@ struct TagDisplayView: View {
|
||||
|
||||
var body: some View {
|
||||
FlowHStack {
|
||||
ForEach(tags, id: \.id) { tag in
|
||||
ForEach(tags, id: \.identifier) { tag in
|
||||
TagView(text: tag.localized(in: language).name)
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Button(action: { showTagPicker = true }) {
|
||||
Image(systemSymbol: .squareAndPencilCircleFill)
|
||||
|
||||
@@ -27,7 +27,7 @@ struct TagPickerView: View {
|
||||
Text("Select a tag to link to")
|
||||
List(content.tags, selection: $newSelection) { tag in
|
||||
let loc = tag.localized(in: language)
|
||||
Text("\(loc.title) (\(tag.id))")
|
||||
Text("\(loc.title) (\(tag.identifier))")
|
||||
.tag(tag)
|
||||
}
|
||||
.frame(minHeight: 300)
|
||||
|
||||
@@ -15,7 +15,7 @@ struct TagPropertyView: View {
|
||||
var body: some View {
|
||||
GenericPropertyView(title: title, footer: footer) {
|
||||
HStack {
|
||||
Text(selectedTag?.id ?? "No tag selected")
|
||||
Text(selectedTag?.identifier ?? "No tag selected")
|
||||
Spacer()
|
||||
Button("Select") {
|
||||
showTagSelectionSheet = true
|
||||
|
||||
@@ -20,7 +20,7 @@ final class InsertableFileButton: ObservableObject {
|
||||
"""
|
||||
icon: \(label.icon.rawValue)
|
||||
text: \(label.value)
|
||||
file: \(file.id)
|
||||
file: \(file.identifier)
|
||||
"""
|
||||
guard let downloadedFileName else {
|
||||
return result
|
||||
@@ -86,7 +86,7 @@ struct InsertableButtons: View, InsertableCommandView {
|
||||
var id: String {
|
||||
switch self {
|
||||
case .file(let file):
|
||||
return "file-\(file.file?.id ?? "none")"
|
||||
return "file-\(file.file?.identifier ?? "none")"
|
||||
case .url(let url):
|
||||
return "url-\(url.url)"
|
||||
case .event(let event):
|
||||
@@ -161,7 +161,7 @@ private struct FileButtonView: View {
|
||||
var body: some View {
|
||||
HStack {
|
||||
LabelEditingView(label: $content.label)
|
||||
Button("\(content.file?.id ?? "Select file")", action: { showFileSelectionSheet = true })
|
||||
Button("\(content.file?.identifier ?? "Select file")", action: { showFileSelectionSheet = true })
|
||||
OptionalTextField("", text: $content.downloadedFileName, prompt: "Downloaded file name")
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ struct InsertableGallery: View, InsertableCommandView {
|
||||
}
|
||||
return (
|
||||
["```\(GalleryBlock.blockId)"] +
|
||||
images.map { $0.id } +
|
||||
images.map { $0.identifier } +
|
||||
["```"]
|
||||
).joined(separator: "\n")
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ struct InsertableImage: View, InsertableCommandView {
|
||||
return nil
|
||||
}
|
||||
guard let caption else {
|
||||
return ")"
|
||||
return ")"
|
||||
}
|
||||
return ";\(caption))"
|
||||
return ";\(caption))"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,11 @@ struct InsertableLink: View, InsertableCommandView {
|
||||
case .post, .tagOverview:
|
||||
return nil
|
||||
case .page:
|
||||
return selectedPage?.id
|
||||
return selectedPage?.identifier
|
||||
case .tag:
|
||||
return selectedTag?.id
|
||||
return selectedTag?.identifier
|
||||
case .file:
|
||||
return selectedFile?.id
|
||||
return selectedFile?.identifier
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ struct InsertableRoute: View, InsertableCommandView {
|
||||
return nil
|
||||
}
|
||||
var result = ["```route"]
|
||||
result.append("\(RouteBlock.Key.image.rawValue): \(selectedImage.id)")
|
||||
result.append("\(RouteBlock.Key.file.rawValue): \(dataFile.id)")
|
||||
result.append("\(RouteBlock.Key.image.rawValue): \(selectedImage.identifier)")
|
||||
result.append("\(RouteBlock.Key.file.rawValue): \(dataFile.identifier)")
|
||||
if components != Set(RouteStatisticType.allCases) {
|
||||
let list = components
|
||||
.map { $0.rawValue }
|
||||
@@ -57,7 +57,7 @@ struct InsertableRoute: View, InsertableCommandView {
|
||||
if let mapTitle {
|
||||
result.append("\(RouteBlock.Key.mapTitle.rawValue): \(mapTitle)")
|
||||
}
|
||||
result.append("\n```")
|
||||
result.append("```")
|
||||
return result.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
130
CHDataManagement/Views/Pages/Commands/Insert+Video.swift
Normal file
130
CHDataManagement/Views/Pages/Commands/Insert+Video.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
struct InsertableVideo: View, InsertableCommandView {
|
||||
|
||||
final class Model: ObservableObject, InsertableCommandModel {
|
||||
|
||||
@Published
|
||||
var posterImage: FileResource?
|
||||
|
||||
@Published
|
||||
var videoH265: FileResource?
|
||||
|
||||
@Published
|
||||
var videoH264: FileResource?
|
||||
|
||||
@Published
|
||||
var videoWebm: FileResource?
|
||||
|
||||
@Published
|
||||
var controls: Bool = false
|
||||
|
||||
@Published
|
||||
var autoplay: Bool = false
|
||||
|
||||
@Published
|
||||
var loop: Bool = false
|
||||
|
||||
@Published
|
||||
var muted: Bool = false
|
||||
|
||||
@Published
|
||||
var playsinline = false
|
||||
|
||||
@Published
|
||||
var preload = false
|
||||
|
||||
@Published
|
||||
var preloadType: VideoBlock.Option.Preload = .metadata
|
||||
|
||||
var isReady: Bool {
|
||||
videoH265 != nil || videoH264 != nil
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
}
|
||||
|
||||
var command: String? {
|
||||
var lines: [String] = []
|
||||
lines.append("```video")
|
||||
if let posterImage {
|
||||
lines.append("\(VideoBlock.Key.poster): \(posterImage.identifier)")
|
||||
}
|
||||
if let videoH265 {
|
||||
lines.append("\(VideoBlock.Key.h265): \(videoH265.identifier)")
|
||||
}
|
||||
if let videoH264 {
|
||||
lines.append("\(VideoBlock.Key.h264): \(videoH264.identifier)")
|
||||
}
|
||||
if let videoWebm {
|
||||
lines.append("\(VideoBlock.Key.webm): \(videoWebm.identifier)")
|
||||
}
|
||||
if controls { lines.append(VideoBlock.Key.controls.rawValue) }
|
||||
if autoplay { lines.append(VideoBlock.Key.autoplay.rawValue) }
|
||||
if loop { lines.append(VideoBlock.Key.loop.rawValue) }
|
||||
if muted { lines.append(VideoBlock.Key.muted.rawValue) }
|
||||
if playsinline { lines.append(VideoBlock.Key.playsinline.rawValue) }
|
||||
if preload {
|
||||
lines.append("\(VideoBlock.Key.preload.rawValue):\(preloadType.rawValue)")
|
||||
}
|
||||
|
||||
lines.append("```")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
static let title = "Video"
|
||||
|
||||
static let sheetTitle = "Insert video"
|
||||
|
||||
static let icon: SFSymbol = .movieclapper
|
||||
|
||||
@ObservedObject
|
||||
private var model: Model
|
||||
|
||||
init(model: Model) {
|
||||
self.model = model
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
FilePropertyView(
|
||||
title: "Video (h265)",
|
||||
footer: "Select the video encoded using h265",
|
||||
selectedFile: $model.videoH265,
|
||||
allowedType: .video)
|
||||
FilePropertyView(
|
||||
title: "Video (h264)",
|
||||
footer: "Select the video encoded using h264",
|
||||
selectedFile: $model.videoH264,
|
||||
allowedType: .video)
|
||||
FilePropertyView(
|
||||
title: "Poster image",
|
||||
footer: "Select the image to show as the poster",
|
||||
selectedFile: $model.posterImage,
|
||||
allowedType: .image)
|
||||
HStack {
|
||||
Toggle("controls", isOn: $model.controls)
|
||||
Toggle("autoplay", isOn: $model.autoplay)
|
||||
Toggle("loop", isOn: $model.loop)
|
||||
Toggle("muted", isOn: $model.muted)
|
||||
Toggle("playsinline", isOn: $model.playsinline)
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Toggle("preload", isOn: $model.preload)
|
||||
Picker("", selection: $model.preloadType) {
|
||||
ForEach(VideoBlock.Option.Preload.allCases, id: \.rawValue) { type in
|
||||
Text("\(type.rawValue)").tag(type)
|
||||
}
|
||||
}
|
||||
.disabled(!model.preload)
|
||||
.frame(maxWidth: 100)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,8 @@ struct InsertableItemsView: View {
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text("Commands")
|
||||
.font(.headline)
|
||||
InsertableView<InsertableImage>()
|
||||
InsertableView<InsertableVideo>()
|
||||
InsertableView<InsertableGallery>()
|
||||
InsertableView<InsertableLabels>()
|
||||
InsertableView<InsertableButtons>()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -35,7 +35,7 @@ struct PageContentResultsView: View {
|
||||
TextWithSymbol(
|
||||
symbol: $0.type.category.symbol,
|
||||
color: .blue,
|
||||
text: $0.id)
|
||||
text: $0.identifier)
|
||||
}
|
||||
+ results.missingFiles.keys.map {
|
||||
TextWithSymbol(
|
||||
|
||||
@@ -32,7 +32,7 @@ struct PageContentView: View {
|
||||
if page.isExternalUrl {
|
||||
VStack {
|
||||
PageTitleView(page: page.localized(in: language))
|
||||
.id(page.id + language.rawValue)
|
||||
.id(page.identifier + language.rawValue)
|
||||
Spacer()
|
||||
Text("No content available for external page")
|
||||
.font(.title)
|
||||
@@ -42,10 +42,10 @@ struct PageContentView: View {
|
||||
} else {
|
||||
VStack(alignment: .leading) {
|
||||
PageTitleView(page: page.localized(in: language))
|
||||
.id(page.id + language.rawValue)
|
||||
.id(page.identifier + language.rawValue)
|
||||
TagDisplayView(tags: $page.tags)
|
||||
LocalizedPageContentView(page: page, language: language)
|
||||
.id(page.id + language.rawValue)
|
||||
.id(page.identifier + language.rawValue)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ struct PageDetailView: View {
|
||||
@EnvironmentObject
|
||||
private var content: Content
|
||||
|
||||
@EnvironmentObject
|
||||
private var selection: SelectedContent
|
||||
|
||||
@ObservedObject
|
||||
private var page: Page
|
||||
|
||||
@@ -27,10 +30,11 @@ struct PageDetailView: View {
|
||||
title: "Page",
|
||||
text: "A page contains longer content")
|
||||
IdPropertyView(
|
||||
id: $page.id,
|
||||
id: $page.identifier,
|
||||
footer: "The page id is used to link to it internally.",
|
||||
validation: page.isValid,
|
||||
update: { page.update(id: $0) })
|
||||
.id(page.id)
|
||||
|
||||
OptionalStringPropertyView(
|
||||
title: "External url",
|
||||
@@ -72,11 +76,21 @@ struct PageDetailView: View {
|
||||
isExternalPage: page.isExternalUrl,
|
||||
page: page.localized(in: language),
|
||||
transferImage: transferImage)
|
||||
.id(page.id + language.rawValue)
|
||||
.id(page.identifier + language.rawValue)
|
||||
ColoredButton(delete: deletePage)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func deletePage() {
|
||||
guard content.storage.delete(page: page.identifier) else {
|
||||
print("Page '\(page.identifier)': Failed to delete file in content folder")
|
||||
return
|
||||
}
|
||||
content.remove(page)
|
||||
selection.remove(page)
|
||||
}
|
||||
}
|
||||
|
||||
extension PageDetailView: MainContentView {
|
||||
|
||||
@@ -7,5 +7,6 @@ struct LinkedPageTagView: View {
|
||||
|
||||
var body: some View {
|
||||
TagDisplayView(tags: $page.tags)
|
||||
.id(page.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ struct PagePickerView: View {
|
||||
Text("Select a page to link to")
|
||||
List(content.pages, selection: $newSelection) { page in
|
||||
let loc = page.localized(in: language)
|
||||
Text("\(loc.title) (\(page.id))")
|
||||
Text("\(loc.title) (\(page.identifier))")
|
||||
.tag(page)
|
||||
}
|
||||
.frame(minHeight: 300)
|
||||
|
||||
@@ -15,11 +15,25 @@ struct PostContentView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LocalizedPostContentView(
|
||||
post: post.localized(in: language),
|
||||
other: post.localized(in: language.next),
|
||||
tags: $post.tags,
|
||||
page: $post.linkedPage)
|
||||
let localized = post.localized(in: language)
|
||||
let other = post.localized(in: language.next)
|
||||
VStack(alignment: .leading) {
|
||||
PostImagesView(
|
||||
post: localized,
|
||||
other: other)
|
||||
PostTitleView(post: localized)
|
||||
if let page = post.linkedPage {
|
||||
LinkedPageTagView(page: page)
|
||||
} else {
|
||||
TagDisplayView(tags: $post.tags)
|
||||
}
|
||||
PostLabelsView(
|
||||
post: post,
|
||||
localized: localized,
|
||||
other: other)
|
||||
PostTextView(post: localized)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
struct PostDetailView: View {
|
||||
|
||||
@@ -32,15 +33,29 @@ struct PostDetailView: View {
|
||||
title: "Post",
|
||||
text: "Posts capture quick updates and can link to pages")
|
||||
|
||||
if post.linkedPage == nil {
|
||||
Button("Create page", action: createPageFromPost)
|
||||
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",
|
||||
fillColor: .blue,
|
||||
textColor: .white,
|
||||
action: createPageFromPost)
|
||||
}
|
||||
|
||||
IdPropertyView(
|
||||
id: $post.id,
|
||||
id: $post.identifier,
|
||||
footer: "The id is used to link to post and store them",
|
||||
validation: post.isValid,
|
||||
update: { post.update(id: $0) })
|
||||
.id(post.id)
|
||||
|
||||
BoolPropertyView(
|
||||
title: "Draft",
|
||||
@@ -63,6 +78,7 @@ struct PostDetailView: View {
|
||||
selectedPage: $post.linkedPage,
|
||||
footer: "The page to open when clicking on the post")
|
||||
.onChange(of: post.linkedPage) { oldValue, newValue in
|
||||
|
||||
if newValue != nil {
|
||||
post.tags = []
|
||||
} else {
|
||||
@@ -73,9 +89,16 @@ 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)
|
||||
ColoredButton(delete: deletePost)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -90,6 +113,20 @@ struct PostDetailView: View {
|
||||
selection.tab = .pages
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
return
|
||||
}
|
||||
content.remove(post)
|
||||
selection.remove(post)
|
||||
}
|
||||
}
|
||||
|
||||
extension PostDetailView: MainContentView {
|
||||
|
||||
@@ -18,7 +18,7 @@ struct PostImageView: View {
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(height: 100)
|
||||
Text(image.id)
|
||||
Text(image.identifier)
|
||||
.font(.title)
|
||||
Text("Failed to load image")
|
||||
.font(.body)
|
||||
@@ -32,7 +32,7 @@ struct PostImageView: View {
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(height: 100)
|
||||
Text(image.id)
|
||||
Text(image.identifier)
|
||||
.font(.title)
|
||||
Button("Generate preview") {
|
||||
generateVideoPreview(image)
|
||||
@@ -48,7 +48,7 @@ struct PostImageView: View {
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(height: 100)
|
||||
Text(image.id)
|
||||
Text(image.identifier)
|
||||
.font(.title)
|
||||
Text("Invalid media type")
|
||||
.font(.body)
|
||||
|
||||
@@ -1,38 +1,22 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LocalizedPostContentView: View {
|
||||
struct PostImagesView: View {
|
||||
|
||||
@Environment(\.language)
|
||||
private var language
|
||||
|
||||
@EnvironmentObject
|
||||
private var content: Content
|
||||
|
||||
@ObservedObject
|
||||
var post: LocalizedPost
|
||||
|
||||
@ObservedObject
|
||||
var other: LocalizedPost
|
||||
|
||||
@Binding
|
||||
var tags: [Tag]
|
||||
|
||||
@Binding
|
||||
var page: Page?
|
||||
|
||||
@State
|
||||
private var fileTypeToSelect: FileTypeCategory = .image
|
||||
|
||||
@State
|
||||
private var showImagePicker = false
|
||||
|
||||
init(post: LocalizedPost, other: LocalizedPost, tags: Binding<[Tag]>, page: Binding<Page?>) {
|
||||
self.post = post
|
||||
self.other = other
|
||||
self._tags = tags
|
||||
self._page = page
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
@@ -60,36 +44,11 @@ struct LocalizedPostContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
OptionalTextField("", text: $post.title)
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(Color.primary)
|
||||
.textFieldStyle(.plain)
|
||||
.lineLimit(2)
|
||||
.frame(minHeight: 30)
|
||||
if let page = page {
|
||||
LinkedPageTagView(page: page)
|
||||
} else {
|
||||
TagDisplayView(tags: $tags)
|
||||
}
|
||||
PostLabelsView(post: post, other: other)
|
||||
TextEditor(text: $post.text)
|
||||
.font(.body)
|
||||
.frame(minHeight: 150)
|
||||
.textEditorStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.leading, 3)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding()
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
MultiFileSelectionView(
|
||||
selectedFiles: $post.images,
|
||||
allowedType: fileTypeToSelect)
|
||||
}
|
||||
}
|
||||
|
||||
private func copyImagesFromOtherLanguage() {
|
||||
post.images = other.images
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user