Compare commits

...

34 Commits

Author SHA1 Message Date
Christoph Hagen
544c31643f Fix unused file removal 2026-01-08 22:40:47 +01:00
Christoph Hagen
57fa5aa3dd Fix file metadata save 2026-01-08 22:40:25 +01:00
Christoph Hagen
07ba77e337 Improve path settings, add icons 2025-12-20 12:06:59 +01:00
Christoph Hagen
9848de02cb Determine video codecs 2025-08-31 18:04:00 +02:00
Christoph Hagen
96bd07bdb7 Begin statistics creation 2025-08-31 16:27:32 +02:00
Christoph Hagen
f972a2c020 Generate labels from workout 2025-08-22 00:01:51 +02:00
Christoph Hagen
9ec207014c Add route files, show overview 2025-08-21 20:26:22 +02:00
Christoph Hagen
43b761b593 Determine required files from custom HTML 2025-07-12 09:22:33 +02:00
Christoph Hagen
ba6097a67b Save changes to settings 2025-06-29 19:16:11 +02:00
Christoph Hagen
5ac8991c48 Fix links in posts 2025-06-16 10:36:22 +02:00
Christoph Hagen
8508719dbe Fix tag assignment in post UI 2025-06-16 10:09:38 +02:00
Christoph Hagen
1d0eba9d78 Fix id of Items, saving 2025-06-11 08:19:44 +02:00
Christoph Hagen
5970ce2e9f Improve video command 2025-06-08 17:09:43 +02:00
Christoph Hagen
73d9c4ec29 Add video command 2025-06-08 17:02:05 +02:00
Christoph Hagen
ee2993318f Fix route button, improve file details 2025-05-11 15:51:36 +02:00
Christoph Hagen
afa2e0b844 More actions for generation sheet 2025-05-04 21:49:16 +02:00
Christoph Hagen
d779b7a42c Improve buttons 2025-05-04 20:59:45 +02:00
Christoph Hagen
f968ccad29 First actions for generation view 2025-05-04 20:57:49 +02:00
Christoph Hagen
a4710d525b Add button to remove a tag 2025-05-04 11:55:54 +02:00
Christoph Hagen
a8920a4cd2 Remove selection when deleting file 2025-05-04 11:48:58 +02:00
Christoph Hagen
cb041eb6ed Add button to delete page 2025-05-04 11:48:31 +02:00
Christoph Hagen
329519e15b Add button to remove post 2025-05-04 11:48:09 +02:00
Christoph Hagen
d6502fb09c Show source of missing page links 2025-05-04 11:47:20 +02:00
Christoph Hagen
dd720d6646 Show drafts in generation view 2025-05-04 10:13:59 +02:00
Christoph Hagen
e689903f3c Fix image dimension crash 2025-05-04 09:36:37 +02:00
Christoph Hagen
062e7d289a Prevent saving when some file properties change 2025-05-04 08:57:16 +02:00
Christoph Hagen
4f31622abe Fix label editing view 2025-05-02 23:15:01 +02:00
Christoph Hagen
d0685c163c Update page indicators 2025-05-02 23:14:47 +02:00
Christoph Hagen
1f4f32c9af Improve content saving, label editing 2025-05-02 22:11:43 +02:00
Christoph Hagen
fea06a93b7 Improve route statistics 2025-05-02 14:54:41 +02:00
Christoph Hagen
3b2cc75fc3 Bump version 2025-05-02 10:01:07 +02:00
Christoph Hagen
26e74d0952 Improve header handling for post lists 2025-05-02 10:00:59 +02:00
Christoph Hagen
fa2f749b70 Add image gallery block 2025-05-02 10:00:22 +02:00
Christoph Hagen
b3c982b2b9 Update SF Symbols 2025-05-02 09:54:53 +02:00
140 changed files with 4216 additions and 638 deletions

View File

@@ -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,14 +210,36 @@
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 */; };
E2EC1FAB2DC0C99600C41784 /* RouteViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FAA2DC0C98C00C41784 /* RouteViews.swift */; };
E2EC1FAD2DC0D2FA00C41784 /* RouteLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FAC2DC0D2FA00C41784 /* RouteLocalization.swift */; };
E2EC1FB02DC0D7DA00C41784 /* RouteBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */; };
E2EC1FB22DC0D8BD00C41784 /* RouteViewComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FB12DC0D8BD00C41784 /* RouteViewComponents.swift */; };
E2EC1FB22DC0D8BD00C41784 /* RouteStatisticType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FB12DC0D8BD00C41784 /* RouteStatisticType.swift */; };
E2EC1FB42DC0FA8700C41784 /* Insert+Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */; };
E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */; };
E2F3B3852DC49B7A00CFA712 /* Insert+Gallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3842DC49B4400CFA712 /* Insert+Gallery.swift */; };
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 */; };
@@ -229,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 */; };
@@ -290,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>"; };
@@ -309,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>"; };
@@ -368,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>"; };
@@ -445,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>"; };
@@ -468,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>"; };
@@ -478,8 +544,16 @@
E2EC1FAA2DC0C98C00C41784 /* RouteViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteViews.swift; sourceTree = "<group>"; };
E2EC1FAC2DC0D2FA00C41784 /* RouteLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteLocalization.swift; sourceTree = "<group>"; };
E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteBlock.swift; sourceTree = "<group>"; };
E2EC1FB12DC0D8BD00C41784 /* RouteViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteViewComponents.swift; sourceTree = "<group>"; };
E2EC1FB12DC0D8BD00C41784 /* RouteStatisticType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteStatisticType.swift; sourceTree = "<group>"; };
E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Route.swift"; sourceTree = "<group>"; };
E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryBlock.swift; sourceTree = "<group>"; };
E2F3B3842DC49B4400CFA712 /* Insert+Gallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Gallery.swift"; sourceTree = "<group>"; };
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>"; };
@@ -506,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>"; };
@@ -571,6 +644,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E224E0E52E56528F0031C2B0 /* BinaryCodable in Frameworks */,
E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */,
E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */,
E2FD1D522D4644B400B48627 /* SVGView in Frameworks */,
@@ -589,6 +663,8 @@
E20BCCA02D53985500B8DBEB /* Generation */ = {
isa = PBXGroup;
children = (
E2F3B3A52DC7F60E00CFA712 /* GenerationIssuesActionView.swift */,
E2F3B3A32DC7DC1F00CFA712 /* GenerationIssuesView.swift */,
E20BCCAE2D53F4A500B8DBEB /* GenerationStringIssuesView.swift */,
E20BCCAC2D53F48100B8DBEB /* IssueStatus.swift */,
E20BCCAA2D53B85300B8DBEB /* GenerationResultsIssueView.swift */,
@@ -669,9 +745,30 @@
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 = (
E2F3B3972DC54F8600CFA712 /* ChangeObservingItem.swift */,
E2FD1D1E2D2E9CBE00B48627 /* ItemId.swift */,
E2FD1D1C2D2DE31600B48627 /* ItemType.swift */,
E2FE0F162D2698D5002963B7 /* LocalizedPageId.swift */,
@@ -686,6 +783,7 @@
E25DA53B2D0042EA00AEF16D /* Settings */ = {
isa = PBXGroup;
children = (
E29A577D2E9E444000B19DA3 /* ToolSettings.swift */,
E2FD1D2D2D37180600B48627 /* GeneralSettings.swift */,
E2FE0F392D2B3E4E002963B7 /* AudioPlayerSettings.swift */,
E2FE0F6D2D2D3685002963B7 /* LocalizedAudioPlayerSettings.swift */,
@@ -713,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;
@@ -818,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 */,
@@ -991,11 +1093,15 @@
E2B85F4B2C4B8B7F0047CD0C /* Posts */ = {
isa = PBXGroup;
children = (
E2F3B39B2DC5542E00CFA712 /* LabelEditingView.swift */,
E2FD1D632D47EF4200B48627 /* DetailListItem.swift */,
E2FD1D452D46427B00B48627 /* PageIconView.swift */,
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 */,
@@ -1003,7 +1109,6 @@
E2A21C072CB17B810060935B /* TagView.swift */,
E29D31312D03B5610051B7F4 /* LocalizedPostDetailView.swift */,
E2FD1D5D2D47EED200B48627 /* PostImageView.swift */,
E2FD1D5F2D47EEEF00B48627 /* LocalizedPostContentView.swift */,
E2FD1D5B2D47EEB800B48627 /* LinkedPageTagView.swift */,
);
path = Posts;
@@ -1012,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 */,
@@ -1028,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 = (
@@ -1048,6 +1176,7 @@
E2DD04722C276F31003BFF1F /* CHDataManagement */ = {
isa = PBXGroup;
children = (
E224E0D72E55074E0031C2B0 /* Workouts */,
E2B482162D63AF6F005C309D /* Notifications */,
E2B4820E2D5E9FF0005C309D /* Push */,
E2B482012D5D1325005C309D /* Server */,
@@ -1077,7 +1206,7 @@
E2EC1FAE2DC0D30100C41784 /* Routes */ = {
isa = PBXGroup;
children = (
E2EC1FB12DC0D8BD00C41784 /* RouteViewComponents.swift */,
E2EC1FB12DC0D8BD00C41784 /* RouteStatisticType.swift */,
E2EC1FAC2DC0D2FA00C41784 /* RouteLocalization.swift */,
E2EC1FAA2DC0C98C00C41784 /* RouteViews.swift */,
);
@@ -1097,6 +1226,7 @@
E2FD1D352D3BBCAF00B48627 /* Commands */ = {
isa = PBXGroup;
children = (
E2F3B39D2DC55B1C00CFA712 /* LabelCreationView.swift */,
E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */,
E2FD1D592D477AB200B48627 /* InsertableItemsView.swift */,
E2FD1D572D477A9400B48627 /* InsertableCommand.swift */,
@@ -1106,6 +1236,8 @@
E2FD1D362D3BBCB500B48627 /* Insert+Image.swift */,
E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */,
E2FD1D552D46CED500B48627 /* Insert+Labels.swift */,
E2F3B3842DC49B4400CFA712 /* Insert+Gallery.swift */,
E2720B872DF38BB200FDB543 /* Insert+Video.swift */,
);
path = Commands;
sourceTree = "<group>";
@@ -1147,6 +1279,8 @@
E2FE0F342D2B27E6002963B7 /* Blocks */ = {
isa = PBXGroup;
children = (
E2DBA3B02E58F57800F1E143 /* WorkoutBlock.swift */,
E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */,
E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */,
E2B482212D676BEB005C309D /* PhoneScreensBlock.swift */,
E2FE0F652D2C3B33002963B7 /* LabelsBlock.swift */,
@@ -1244,6 +1378,7 @@
E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */,
E2FD1D512D4644B400B48627 /* SVGView */,
E2B481FF2D5D1136005C309D /* Vapor */,
E224E0E42E56528F0031C2B0 /* BinaryCodable */,
);
productName = CHDataManagement;
productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */;
@@ -1257,7 +1392,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1540;
LastUpgradeCheck = 1630;
LastUpgradeCheck = 2600;
TargetAttributes = {
E2DD046F2C276F31003BFF1F = {
CreatedOnToolsVersion = 15.4;
@@ -1283,6 +1418,7 @@
E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
E2FD1D502D4644B400B48627 /* XCRemoteSwiftPackageReference "SVGView" */,
E2B481FE2D5D1136005C309D /* XCRemoteSwiftPackageReference "vapor" */,
E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */,
);
productRefGroup = E2DD04712C276F31003BFF1F /* Products */;
projectDirPath = "";
@@ -1312,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 */,
@@ -1325,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 */,
@@ -1342,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 */,
@@ -1354,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 */,
@@ -1363,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 */,
@@ -1383,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 */,
@@ -1390,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 */,
@@ -1401,19 +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 */,
@@ -1424,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 */,
@@ -1445,12 +1600,14 @@
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 */,
E2EC1FB22DC0D8BD00C41784 /* RouteViewComponents.swift in Sources */,
E2EC1FB22DC0D8BD00C41784 /* RouteStatisticType.swift in Sources */,
E29D31A12D0C75CA0051B7F4 /* Content+Validation.swift in Sources */,
E29D316D2D07A5050051B7F4 /* PageGenerationResults.swift in Sources */,
E229903E2D0F8F02009F8D77 /* StringPropertyView.swift in Sources */,
@@ -1468,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 */,
@@ -1475,12 +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 */,
@@ -1501,8 +1663,10 @@
E2FD1D1F2D2E9CC200B48627 /* ItemId.swift in Sources */,
E2FE0EFA2D25AFBA002963B7 /* PageHeader.swift in Sources */,
E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */,
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 */,
@@ -1520,6 +1684,7 @@
E2FE0F112D268E7E002963B7 /* MarkdownCodeProcessor.swift in Sources */,
E2B482102D5E9FF9005C309D /* RemotePush.swift in Sources */,
E22990202D0ECBE5009F8D77 /* TagOverviewDetailView.swift in Sources */,
E2F3B3852DC49B7A00CFA712 /* Insert+Gallery.swift in Sources */,
E29D31C02D0DB9F20051B7F4 /* AudioPlayerContent.swift in Sources */,
E22990192D0E3546009F8D77 /* ItemReference.swift in Sources */,
E2FD1D372D3BBCCA00B48627 /* Insert+Image.swift in Sources */,
@@ -1527,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 */,
@@ -1536,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 */,
@@ -1551,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 */,
@@ -1643,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";
};
@@ -1699,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;
@@ -1731,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;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -1770,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;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -1805,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";
@@ -1866,7 +2048,7 @@
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 5.3.0;
minimumVersion = 6.0.0;
};
};
E2FD1D502D4644B400B48627 /* XCRemoteSwiftPackageReference "SVGView" */ = {
@@ -1880,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" */;

View File

@@ -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",
@@ -132,8 +141,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/SFSafeSymbols/SFSafeSymbols",
"state" : {
"revision" : "e2e28f4e56e1769c2ec3c61c9355fc64eb7a535a",
"version" : "5.3.0"
"revision" : "3dd282d3269b061853a3b3bcd23a509d2aa166ce",
"version" : "6.2.0"
}
},
{

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View 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
}
}

View 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"
}
}

View File

@@ -17,6 +17,8 @@ enum ContentBlock: String, CaseIterable {
case route
case gallery
var processor: BlockProcessor.Type {
switch self {
case .audio: return AudioBlock.self
@@ -27,6 +29,7 @@ enum ContentBlock: String, CaseIterable {
case .labels: return LabelsBlock.self
case .screens: return PhoneScreensBlock.self
case .route: return RouteBlock.self
case .gallery: return GalleryBlock.self
}
}
}

View File

@@ -0,0 +1,46 @@
struct GalleryBlock: BlockLineProcessor {
static let blockId: ContentBlock = .gallery
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 imageWidth: Int {
content.settings.pages.contentWidth
}
func process(_ lines: [String], markdown: Substring) -> String {
var images = [FileResource]()
for line in lines {
let imageId = line.trimmed
guard !imageId.isEmpty else { continue }
guard let image = content.image(imageId) else {
results.missing(file: imageId, source: "Route block")
continue
}
images.append(image)
}
guard let firstImage = images.first else { return "" }
let imageSets = images.map {
$0.imageSet(width: imageWidth, height: imageWidth, language: language)
}
imageSets.forEach(results.require)
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)
return gallery.content
}
}

View File

@@ -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 ""
}

View File

@@ -33,20 +33,33 @@ struct RouteBlock: KeyedBlockProcessor {
}
func process(_ arguments: [Key : String], markdown: Substring) -> String {
let rawComponents = arguments[.components] ?? "all"
guard let imageId = arguments[.image],
let fileId = arguments[.file],
let components = RouteViewComponents(rawValue: rawComponents) else {
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 image = content.image(imageId) else {
results.missing(file: imageId, source: "Route block")
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)
@@ -66,7 +79,7 @@ struct RouteBlock: KeyedBlockProcessor {
localization: language == .english ? .english : .german,
chartTitle: arguments[.chartTitle],
chartId: "chart-" + id,
components: components,
displayedTypes: displayedTypes,
mapTitle: arguments[.mapTitle],
mapId: "map-" + id,
filePath: file.absoluteUrl,
@@ -76,6 +89,7 @@ struct RouteBlock: KeyedBlockProcessor {
caption: arguments[.caption])
results.require(footer: views.script)
results.require(icons: displayedTypes.map { $0.icon })
return views.content
}

View File

@@ -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

View 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
}
}

View File

@@ -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) {

View File

@@ -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 ""
}

View File

@@ -95,7 +95,10 @@ extension HeaderElement: Hashable {
extension HeaderElement: Comparable {
static func < (lhs: HeaderElement, rhs: HeaderElement) -> Bool {
lhs.order < rhs.order
guard lhs.order == rhs.order else {
return lhs.order < rhs.order
}
return lhs.content < rhs.content
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View 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
}
}
}

View File

@@ -11,15 +11,16 @@ final class FeedPageGenerator {
self.results = results
}
private func includeSwiper(in headers: inout Set<HeaderElement>) {
if let swiperCss = content.settings.posts.swiperCssFile {
headers.insert(.css(file: swiperCss, order: HeaderElement.swiperCssFileOrder))
results.require(file: swiperCss)
private func makeHeaders(requiredItems: Set<KnownHeaderElement>, results: PageGenerationResults) -> Set<HeaderElement> {
var result = content.postPageHeaders
for item in requiredItems {
guard let header = item.header(content: content) else {
results.warning("Header \(item) not configured in settings")
continue
}
if let swiperJs = content.settings.posts.swiperJsFile {
headers.insert(.js(file: swiperJs, defer: true))
results.require(file: swiperJs)
result.insert(header)
}
return result
}
func generatePage(language: ContentLanguage,
@@ -33,13 +34,16 @@ final class FeedPageGenerator {
totalPages: Int,
languageButtonUrl: String,
linkPrefix: String) -> String {
var headers = content.postPageHeaders
var footer = ""
var requiredHeaders = Set<KnownHeaderElement>()
if posts.contains(where: { $0.requiresSwiper }) {
// Sort swiper style sheet before default style sheet
includeSwiper(in: &headers)
requiredHeaders.formUnion([.swiperJs, .swiperCss])
footer = swiperInitScript(posts: posts)
}
let headers = makeHeaders(requiredItems: requiredHeaders, results: results)
results.require(files: headers.compactMap { $0.requiredFile })
results.require(headers: requiredHeaders)
let iconUrl = content.settings.navigation.localized(in: language).rootUrl
let languageButton = NavigationBar.Link(
@@ -84,14 +88,7 @@ final class FeedPageGenerator {
}
func swiperInitScript(posts: [FeedEntryData]) -> String {
var result = "<script> window.onload = () => { "
for post in posts {
guard post.requiresSwiper else {
continue
}
result += ImageGallery.swiperInit(id: post.entryId)
}
result += "}; </script>"
return result
let ids = posts.filter { $0.requiresSwiper }.map { $0.entryId }
return ImageGallery.combinedFootor(ids: ids)
}
}

View File

@@ -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)

View File

@@ -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>"
}
}

View File

@@ -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,
@@ -116,6 +127,8 @@ final class PostListPageGenerator {
// Includes leading slash
let pageUrl = pageUrl(in: language, pageNumber: pageIndex)
#warning("Use results for each individual posts page")
let fileContent = feedPageGenerator.generatePage(
language: language,
posts: posts,

View File

@@ -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> {

View File

@@ -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
}
@@ -242,6 +248,10 @@ final class PageGenerationResults: ObservableObject {
onMain { self.requiredHeaders.formUnion(headers) }
}
func require<S>(headers: S) where S: Sequence, S.Element == KnownHeaderElement {
onMain { self.requiredHeaders.formUnion(headers) }
}
func require(icon: PageIcon) {
onMain { self.requiredIcons.insert(icon) }
}
@@ -254,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) }
}
@@ -280,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) {

View 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
}
}
}

View File

@@ -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()

View File

@@ -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
}
}
}

View File

@@ -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()

View File

@@ -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" })!
}
}

View File

@@ -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" })!
}
}
}

View File

@@ -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" })!
}
}
}

View File

@@ -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" })!
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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()
}
}
@@ -184,8 +218,10 @@ final class Content: ObservableObject {
private(set) var lastModification: Date = .now
func update(saveState: SaveState) {
DispatchQueue.main.async {
self.saveState = saveState
}
}
func setModificationTimestamp() {
self.lastModification = .now
@@ -194,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
}
}
}

View File

@@ -1,17 +1,10 @@
import Foundation
final class ContentLabel: ObservableObject {
struct ContentLabel {
@Published
var icon: PageIcon
@Published
var value: String
init(icon: PageIcon, value: String) {
self.icon = icon
self.value = value
}
}
extension ContentLabel: Equatable {
@@ -34,7 +27,7 @@ extension ContentLabel {
.init(icon: icon.rawValue, value: value)
}
convenience init?(context: LoadingContext, data: Data) {
init?(context: LoadingContext, data: Data) {
guard let icon = PageIcon(rawValue: data.icon) else {
context.error("Unknown label icon '\(data.icon)'")
return nil

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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"
}
}
@@ -27,10 +29,11 @@ enum FileTypeCategory: String, CaseIterable {
case .image: .photo
case .code: .keyboard
case .model: .cubeTransparent
case .text: .docText
case .text: .textDocument
case .video: .video
case .resource: .docZipper
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
}
}

View File

@@ -0,0 +1,38 @@
import Foundation
import Combine
class ChangeObservingItem: ObservableContentItem {
unowned let content: Content
/// 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) {
self.content = content
observeChanges()
}
// MARK: Change observation
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()
}
}

View File

@@ -1,34 +1,28 @@
import Foundation
import Combine
class Item: ObservableContentItem, Identifiable {
unowned let content: Content
class Item: ChangeObservingItem, Identifiable {
/// A dummy property to force views to update when properties change
@Published
private var changeToggle = false
@Published
var id: String
/// A session-id for the item for identification
let id = UUID()
var cancellables = Set<AnyCancellable>()
/// The unique, persistent identifier of the item
///
/// This identifier is not used for `Identifiable`, since it may be changed through the UI.
@Published
var identifier: String
init(content: Content, id: String) {
self.content = content
self.id = id
self.identifier = id
super.init(content: content)
observeChanges()
}
// MARK: Change observation
func didChange() {
DispatchQueue.main.async {
self.changeToggle.toggle()
}
}
// MARK: Paths
func makeCleanAbsolutePath(_ path: String) -> String {
@@ -56,14 +50,14 @@ class Item: ObservableContentItem, 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
}
}
@@ -71,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
}
}

View File

@@ -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"
}

View File

@@ -31,3 +31,10 @@ extension LocalizedItemId: Comparable {
return lhs.language < rhs.language
}
}
extension LocalizedItemId: CustomStringConvertible {
var description: String {
"\(itemType) (\(language))"
}
}

View File

@@ -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) {

View File

@@ -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) })
}

View File

@@ -6,9 +6,7 @@ import SwiftUI
including the title, url path and required resources
*/
final class LocalizedPage: ObservableObject {
unowned let content: Content
final class LocalizedPage: ChangeObservingItem {
/**
The string to use when creating the url for the page.
@@ -50,19 +48,27 @@ final class LocalizedPage: ObservableObject {
originalUrl: String? = nil,
linkPreview: LinkPreview = .init(),
hideTitle: Bool = false) {
self.content = content
self.urlString = urlString
self.title = title
self.lastModified = lastModified
self.originalUrl = originalUrl
self.linkPreview = linkPreview
self.hideTitle = hideTitle
super.init(content: content)
}
func isValid(urlComponent: String) -> Bool {
content.isValidIdForTagOrPageOrPost(urlComponent) &&
!content.containsPage(withUrlComponent: urlComponent)
}
func update(hasContent: Bool) -> Bool {
if self.hasContent != hasContent {
self.hasContent = hasContent
return true
}
return false
}
}

View File

@@ -1,9 +1,7 @@
import Foundation
import SwiftUI
final class LocalizedPost: ObservableObject {
unowned let content: Content
final class LocalizedPost: ChangeObservingItem {
@Published
var title: String?
@@ -36,7 +34,6 @@ final class LocalizedPost: ObservableObject {
labels: [ContentLabel] = [],
pageLinkText: String? = nil,
linkPreview: LinkPreview = .init()) {
self.content = content
self.title = title
self.text = text
self.lastModified = lastModified
@@ -44,6 +41,7 @@ final class LocalizedPost: ObservableObject {
self.labels = labels
self.pageLinkText = pageLinkText
self.linkPreview = linkPreview
super.init(content: content)
}
func contains(_ string: String) -> Bool {
@@ -98,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,

View File

@@ -1,8 +1,6 @@
import Foundation
final class LocalizedTag: ObservableObject {
unowned let content: Content
final class LocalizedTag: ChangeObservingItem {
@Published
var urlComponent: String
@@ -22,11 +20,11 @@ final class LocalizedTag: ObservableObject {
name: String,
linkPreview: LinkPreview = .init(),
originalUrl: String? = nil) {
self.content = content
self.urlComponent = urlComponent
self.name = name
self.linkPreview = linkPreview
self.originalUrl = originalUrl
super.init(content: content)
}
func isValid(urlComponent: String) -> Bool {

View File

@@ -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,22 +146,26 @@ 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
}
localized(in: language).hasContent = false
if localized(in: language).update(hasContent: false) {
self.didChange()
}
return true
}
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
}
localized(in: language).hasContent = true
if localized(in: language).update(hasContent: true) {
self.didChange()
}
return true
}
@@ -169,8 +173,15 @@ final class Page: Item, DateItem, LocalizedItem {
Update the `hasContent` property of all localized pages.
*/
func updateContentExistence() {
var didUpdate = false
for language in ContentLanguage.allCases {
localized(in: language).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
}
}
if didUpdate {
self.didChange()
}
}
@@ -223,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,
@@ -233,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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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")
}
}

View 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)
}
}

View File

@@ -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)
}
}

View File

@@ -2,6 +2,6 @@
final class TagOverview: Tag {
override var itemId: ItemId {
.init(type: .tagOverview, id: id)
.init(type: .tagOverview, id: identifier)
}
}

View File

@@ -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"/>
"""
}
}

View File

@@ -21,6 +21,10 @@ enum PageIcon: String, CaseIterable {
case bellSlash = "bell-slash"
case pencil
case personPlus = "person-plus"
// MARK: Statistics
case statisticsTime = "time"
@@ -33,6 +37,12 @@ enum PageIcon: String, CaseIterable {
case statisticsEnergy = "energy"
case statisticsStopwatch = "stopwatch"
case statisticsHeart = "heart-pulse"
case statisticsSpeedometer = "speedometer"
// MARK: Buttons
case buttonDownload = "download"
@@ -68,6 +78,9 @@ enum PageIcon: String, CaseIterable {
case .statisticsElevationDown: Icon.Statistics.ElevationDown.self
case .statisticsDistance: Icon.Statistics.Distance.self
case .statisticsEnergy: Icon.Statistics.Energy.self
case .statisticsStopwatch: Icon.Statistics.Stopwatch.self
case .statisticsHeart: Icon.Statistics.HeartPulse.self
case .statisticsSpeedometer: Icon.Statistics.Speedometer.self
case .buttonDownload: Icon.ArrowDown.self
case .buttonExternalLink: Icon.ArrowRight.self
case .buttonGitLink: Icon.Git.self
@@ -88,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
}
}
@@ -102,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"
@@ -113,11 +130,14 @@ enum PageIcon: String, CaseIterable {
case .audioPlayerPrevious: "Audio Player: Previous"
case .audioPlayerNext: "Audio Player: Next"
case .buttonDownload: "Button: Download"
case .statisticsTime: "Time"
case .statisticsElevationUp: "Elevation Up"
case .statisticsElevationDown: "Elevation Down"
case .statisticsDistance: "Distance"
case .statisticsEnergy: "Energy / Calories"
case .statisticsTime: "Clock (Duration)"
case .statisticsElevationUp: "Arrow Up (Elevation Up)"
case .statisticsElevationDown: "Arrow Down (Elevation Down)"
case .statisticsDistance: "Signpost (Distance)"
case .statisticsEnergy: "Flame (Energy / Calories)"
case .statisticsStopwatch: "Stopwatch (Pace)"
case .statisticsHeart: "Heart Rate"
case .statisticsSpeedometer: "Speedometer (Speed)"
}
}

View File

@@ -7,7 +7,7 @@ extension Icon {
static let id = "icon-clock"
static let attributes = "viewBox='0 0 16 16' width='16' height='16'"
static let attributes = "viewBox='0 0 16 16'"
static let content =
"""
@@ -15,11 +15,24 @@ extension Icon {
"""
}
/// [Source](https://icons.getbootstrap.com/icons/stopwatch/)
struct Stopwatch: ContentIcon {
static let id = "icon-stopwatch"
static let attributes = "viewBox='0 0 16 16'"
static let content =
"""
<path fill="currentColor" d="M8.5 5.6a.5.5 0 1 0-1 0v2.9h-3a.5.5 0 0 0 0 1H8a.5.5 0 0 0 .5-.5z"/><path fill="currentColor" d="M6.5 1A.5.5 0 0 1 7 .5h2a.5.5 0 0 1 0 1v.57c1.36.196 2.594.78 3.584 1.64l.012-.013.354-.354-.354-.353a.5.5 0 0 1 .707-.708l1.414 1.415a.5.5 0 1 1-.707.707l-.353-.354-.354.354-.013.012A7 7 0 1 1 7 2.071V1.5a.5.5 0 0 1-.5-.5M8 3a6 6 0 1 0 .001 12A6 6 0 0 0 8 3"/>
"""
}
struct ElevationUp: ContentIcon {
static let id = "icon-elevation-up"
static let attributes = "width='16' height='16'"
static let attributes = "viewBox='0 0 16 16'"
static let content =
"""
@@ -31,7 +44,7 @@ extension Icon {
static let id = "icon-elevation-down"
static let attributes = "width='16' height='16'"
static let attributes = "viewBox='0 0 16 16'"
static let content =
"""
@@ -43,7 +56,7 @@ extension Icon {
static let id = "icon-distance"
static let attributes = "width='16' height='16'"
static let attributes = "viewBox='0 0 16 16'"
static let content =
"""
@@ -55,12 +68,38 @@ extension Icon {
static let id = "icon-energy"
static let attributes = "width='16' height='16'"
static let attributes = "viewBox='0 0 16 16'"
static let content =
"""
<path fill="currentColor" d="M8 16c3.3 0 6-2 6-5.5 0-1.5-.5-4-2.5-6 .3 1.5-1.3 2-1.3 2C11 4 9 .5 6 0c.4 2 .5 4-2 6-1.3 1-2 2.7-2 4.5C2 14 4.7 16 8 16Zm0-1c-1.7 0-3-1-3-2.8 0-.7.3-2 1.3-3-.2.8.7 1.3.7 1.3-.4-1.3.5-3.3 2-3.5-.2 1-.3 2 1 3a3 3 0 0 1 1 2.3C11 14 9.7 15 8 15Z"/>
"""
}
/// [Source](https://icons.getbootstrap.com/icons/heart-pulse/)
struct HeartPulse: ContentIcon {
static let id = "icon-pulse"
static let attributes = "viewBox='0 0 16 16'"
static let content =
"""
<path fill="currentColor" d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053.918 3.995.78 5.323 1.508 7H.43c-2.128-5.697 4.165-8.83 7.394-5.857q.09.083.176.171a3 3 0 0 1 .176-.17c3.23-2.974 9.522.159 7.394 5.856h-1.078c.728-1.677.59-3.005.108-3.947C13.486.878 10.4.28 8.717 2.01zM2.212 10h1.315C4.593 11.183 6.05 12.458 8 13.795c1.949-1.337 3.407-2.612 4.473-3.795h1.315c-1.265 1.566-3.14 3.25-5.788 5-2.648-1.75-4.523-3.434-5.788-5"/><path fill="currentColor" d="M10.464 3.314a.5.5 0 0 0-.945.049L7.921 8.956 6.464 5.314a.5.5 0 0 0-.88-.091L3.732 8H.5a.5.5 0 0 0 0 1H4a.5.5 0 0 0 .416-.223l1.473-2.209 1.647 4.118a.5.5 0 0 0 .945-.049l1.598-5.593 1.457 3.642A.5.5 0 0 0 12 9h3.5a.5.5 0 0 0 0-1h-3.162z"/>
"""
}
/// [Source](https://icons.getbootstrap.com/icons/speedometer/)
struct Speedometer: ContentIcon {
static let id = "icon-speed"
static let attributes = "viewBox='0 0 16 16'"
static let content =
"""
<path fill="currentColor" d="M8 2a.5.5 0 0 1 .5.5V4a.5.5 0 0 1-1 0V2.5A.5.5 0 0 1 8 2M3.732 3.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707M2 8a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 8m9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5m.754-4.246a.39.39 0 0 0-.527-.02L7.547 7.31A.91.91 0 1 0 8.85 8.569l3.434-4.297a.39.39 0 0 0-.029-.518z"/><path fill="currentColor" fill-rule="evenodd" d="M6.664 15.889A8 8 0 1 1 9.336.11a8 8 0 0 1-2.672 15.78zm-4.665-4.283A11.95 11.95 0 0 1 8 10c2.186 0 4.236.585 6.001 1.606a7 7 0 1 0-12.002 0"/>
"""
}
}
}

View File

@@ -11,14 +11,17 @@ struct ImageGallery: HtmlProducer {
/// The images to display
let images: [ImageSet]
let standalone: Bool
/// A version of the id that is safe to use in HTML and JavaScript
private var htmlSafeId: String {
ImageGallery.htmlSafe(id)
}
init(id: String, images: [ImageSet]) {
init(id: String, images: [ImageSet], standalone: Bool = false) {
self.id = id
self.images = images
self.standalone = standalone
}
func populate(_ result: inout String) {
@@ -26,7 +29,7 @@ struct ImageGallery: HtmlProducer {
return
}
result += "<div id='\(htmlSafeId)' class='swiper'><div class='swiper-wrapper'>"
result += "<div id='\(htmlSafeId)' class='swiper\(standalone ? " swiper-standalone" : "")'><div class='swiper-wrapper'>"
let needsPagination = images.count > 1
@@ -53,7 +56,24 @@ struct ImageGallery: HtmlProducer {
id.replacingOccurrences(of: "-", with: "_")
}
static func swiperInit(id: String) -> String {
var javascriptInit: String {
ImageGallery.swiperInit(id: id)
}
var standaloneFooter: String {
"<script> window.onload = () => {\n\(javascriptInit)\n}; </script>"
}
static func combinedFootor(ids: [String]) -> String {
var result = ["<script> window.onload = () => { "]
for id in ids {
result.append(swiperInit(id: id))
}
result.append("}; </script>")
return result.joined(separator: "\n")
}
private static func swiperInit(id: String) -> String {
let id = htmlSafe(id)
return """
var swiper_\(id) = new Swiper("#\(id)", {
@@ -77,25 +97,3 @@ struct ImageGallery: HtmlProducer {
"""
}
}
/*
extension ImageGallery: HTML {
var content: some HTML {
div(.id(id), .class("swiper")) {
div(.class("swiper-wrapper")) {
for image in images {
div(.class("swiper-slide")) {
// TODO: Use different images based on device
img(.src(image.mainImageUrl), .lazyLoad)
div(.class("swiper-lazy-preloader"), .class("swiper-lazy-preloader-white")) { }
}
}
}
div(.class("swiper-button-next")) { }
div(.class("swiper-button-prev")) { }
div(.class("swiper-pagination")) { }
}
}
}
*/

View File

@@ -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>"
}

View File

@@ -1,13 +1,7 @@
struct RouteLocalization {
let elevation: String
let speed: String
let pace: String
let heartRate: String
let statistics: [RouteStatisticType : String]
let fallback: String
@@ -25,10 +19,13 @@ struct RouteLocalization {
extension RouteLocalization {
static let german: RouteLocalization = .init(
elevation: "Höhe",
speed: "Geschw.",
pace: "Pace",
heartRate: "Herzfrequenz",
statistics: [
.elevation: "Höhe",
.speed: "Geschwindigkeit",
.pace: "Pace",
.heartRate: "Herzfrequenz",
.energy: "Aktive Kalorien"
],
fallback: "Zur Anzeige der Statistiken wird JavaScript und Unterstützung für HTML5 Canvas benötigt.",
hourUnit: "Std",
duration: "Dauer",
@@ -37,10 +34,13 @@ extension RouteLocalization {
loadFail: "Die Statistiken konnten nicht geladen werden")
static let english: RouteLocalization = .init(
elevation: "Elevation",
speed: "Speed",
pace: "Pace",
heartRate: "Heart Rate",
statistics: [
.elevation: "Elevation",
.speed: "Speed",
.pace: "Pace",
.heartRate: "Heart Rate",
.energy: "Active Energy"
],
fallback: "Javascript and HTML5 Canvas Support are required to display statistics",
hourUnit: "h",
duration: "Duration",

View File

@@ -0,0 +1,65 @@
enum RouteStatisticType: String, CaseIterable {
case elevation
case speed
case pace
case heartRate = "heart-rate"
case energy
var order: Int {
switch self {
case .elevation: 1
case .speed: 2
case .pace: 3
case .heartRate: 4
case .energy: 5
}
}
var id: String {
switch self {
case .elevation: "elevation"
case .speed: "speed"
case .pace: "pace"
case .heartRate: "hr"
case .energy: "energy"
}
}
var unit: String {
switch self {
case .elevation: "m"
case .speed: "km/h"
case .pace: "min/km"
case .heartRate: "bpm"
case .energy: "kcal/min"
}
}
var icon: PageIcon {
switch self {
case .elevation: .statisticsElevationUp
case .speed: .statisticsSpeedometer
case .pace: .statisticsStopwatch
case .heartRate: .statisticsHeart
case .energy: .statisticsEnergy
}
}
func displayText(in language: ContentLanguage) -> String {
let localization: RouteLocalization = language == .english ? .english : .german
return localization.statistics[self]!
}
}
extension RouteStatisticType: Comparable {
static func < (lhs: RouteStatisticType, rhs: RouteStatisticType) -> Bool {
lhs.order < rhs.order
}
}

View File

@@ -1,7 +0,0 @@
enum RouteViewComponents: String {
case onlyElevation = "elevation"
case all = "all"
case withoutHeartRate = "no-hr"
}

View File

@@ -6,7 +6,7 @@ struct RouteViews: HtmlProducer {
/// The HTML id attribute used to enable fullscreen images
let map: PageImage
let components: RouteViewComponents
let displayedTypes: Set<RouteStatisticType>
let mapId: String
@@ -21,7 +21,7 @@ struct RouteViews: HtmlProducer {
init(localization: RouteLocalization,
chartTitle: String?,
chartId: String,
components: RouteViewComponents,
displayedTypes: Set<RouteStatisticType>,
mapTitle: String?,
mapId: String,
filePath: String,
@@ -31,7 +31,7 @@ struct RouteViews: HtmlProducer {
caption: String?
) {
self.localization = localization
self.components = components
self.displayedTypes = displayedTypes
self.mapId = mapId
self.filePath = filePath
self.map = PageImage(
@@ -47,9 +47,9 @@ struct RouteViews: HtmlProducer {
self.mapTitle = mapTitle
}
var pickerHiddenText: String {
guard components == .onlyElevation else { return "" }
return " style='display:none'"
private func button(series: RouteStatisticType) -> String {
let label = localization.statistics[series]!
return "<button data-type='\(series.id)' unit='\(series.unit)' name='\(label)'>\(series.icon.usageString)<span class='label'>\(label)</span></button>"
}
func populate(_ result: inout String) {
@@ -58,18 +58,19 @@ struct RouteViews: HtmlProducer {
}
map.populate(&result)
let series = displayedTypes.sorted()
guard !series.isEmpty else {
return
}
if let chartTitle {
result += "<h2>\(chartTitle)</h2>"
}
result += "<div id='\(chartId)' class='charts'>"
result += "<div class='picker y-picker'\(pickerHiddenText)>"
result += "<button data-type='elevation' unit='m' class='active'>\(localization.elevation)</button>"
result += "<button data-type='speed' unit='km/h'>\(localization.speed)</button>"
result += "<button data-type='pace' unit='min/km'>\(localization.pace)</button>"
if components == .all {
result += "<button data-type='hr' unit='bpm'>\(localization.heartRate)</button>"
result += "<div class='picker icon-picker y-picker'>"
for type in series {
result += button(series: type)
}
result += "</div>"
result += "</div>" // Picker
result += "<div class='graph'>"
result += "<canvas>"
result += "<div class='fallback'>\(localization.fallback)</div>"
@@ -77,13 +78,13 @@ struct RouteViews: HtmlProducer {
result += "<div class='line'></div>"
result += "<div class='tooltip'></div>"
result += "<div class='load-error'>\(localization.loadFail)</div>"
result += "</div>"
result += "<div class='picker x-picker'\(pickerHiddenText)>"
result += "<button data-type='distance' unit='km' class='active'>\(localization.distance)</button>"
result += "</div>" // Graph
result += "<div class='picker text-picker x-picker'>"
result += "<button data-type='distance' unit='km'>\(localization.distance)</button>"
result += "<button data-type='duration' unit='\(localization.hourUnit)'>\(localization.duration)</button>"
result += "<button data-type='time' unit=''>\(localization.time)</button>"
result += "</div>"
result += "</div>"
result += "</div>" // Picker
result += "</div>" // Chart
}
var script: String {

View File

@@ -46,7 +46,7 @@ struct FeedEntryData {
enum Media {
case images([ImageSet])
case video([FileResource])
case video([PostVideo.Video])
}
var requiresSwiper: Bool {

View File

@@ -12,8 +12,14 @@ struct UploadSheet: View {
@Environment(\.dismiss)
private var dismiss
private let lineLimit = 4
@State
private var output: [String] = ["Ready to upload"]
private var output: [String]
init(output: [String] = ["Ready to upload", "", "", ""]) {
self.output = output
}
private var uploadSymbol: SFSymbol {
if upload.isTransmittingToRemote {
@@ -34,7 +40,7 @@ struct UploadSheet: View {
}
var body: some View {
VStack {
VStack(alignment: .leading) {
HStack {
Button("Upload", action: startUpload)
.disabled(upload.isTransmittingToRemote)
@@ -42,12 +48,26 @@ struct UploadSheet: View {
Spacer()
Button("Close", action: { dismiss() })
}
ScrollView {
Text(output.joined(separator: "\n"))
.font(.body.monospaced())
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading) {
Text(output[0])
Text(output[1])
Text(output[2])
Text(output[3])
}
.font(.body.monospaced())
.lineLimit(1)
// TextField("", text: .constant(output.joined(separator: "\n")))
// .font(.body.monospaced())
// .textFieldStyle(.plain)
// .lineLimit(lineLimit)
// .disabled(true)
// .frame(minHeight: 150)
// ScrollView {
// Text(output.joined(separator: "\n"))
// .font(.body.monospaced())
// .foregroundStyle(.primary)
// .frame(maxWidth: .infinity, alignment: .leading)
// }
}
.padding()
.frame(minWidth: 500, idealWidth: 600)
@@ -55,18 +75,28 @@ struct UploadSheet: View {
private func startUpload() {
guard let folder = content.storage.outputScope?.url.path() else {
output = ["No output folder to start upload"]
output = ["No output folder to start upload", "", "", ""]
return
}
output = ["Starting upload..."]
output = ["Starting upload...", "", "", ""]
upload.transmitToRemote(
settings: content.settings.general,
outputFolder: folder) { newContent in
DispatchQueue.main.async {
let newLines = newContent.components(separatedBy: "\n").suffix(4)
self.output = (self.output + newLines).suffix(4)
let newLines = newContent.components(separatedBy: "\n").suffix(lineLimit)
if newLines.count >= lineLimit {
self.output = newLines.suffix(lineLimit)
} else {
self.output = (self.output + newLines).suffix(lineLimit)
}
}
}
}
}
#Preview {
UploadSheet(output: [
"Some very long text that should cause the view to scroll", "More", "Some", "Yes"
])
}

View File

@@ -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)
}
}

View File

@@ -1,5 +1,5 @@
import SFSafeSymbols
import SwiftUICore
import SwiftUI
enum SaveState {
case storageNotInitialized

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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 {
@@ -59,7 +59,7 @@ struct FileContentView: View {
}.foregroundStyle(.secondary)
case .resource:
VStack {
Image(systemSymbol: .docQuestionmark)
Image(systemSymbol: .questionmarkTextPage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: iconSize)
@@ -77,6 +77,9 @@ struct FileContentView: View {
.font(.title)
}
.foregroundStyle(.secondary)
case .route:
RoutePreviewView(file: file)
}
}
}.padding()

View File

@@ -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)
ColoredButton(
icon: .squareAndArrowDown,
text: "Convert to internal file",
action: replaceFile)
} else {
ButtonIcon(.arrowLeftArrowRight, action: replaceFile)
ButtonIcon(.squareDashed, action: convertToExternal)
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)
}
ButtonIcon(.trash, action: deleteFile)
.foregroundStyle(.red)
}
.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)
}
}

View 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
}

View File

@@ -30,7 +30,7 @@ final class FileToAdd: ObservableObject {
}
var idAlreadyExists: Bool {
content.files.contains { $0.id == uniqueId }
content.files.contains { $0.identifier == uniqueId }
}
}

View File

@@ -10,7 +10,7 @@ struct FileToAddView: View {
var symbol: SFSymbol {
if file.idAlreadyExists {
return .docOnDoc
return .documentOnDocument
}
if file.isSelected {
return .checkmarkCircleFill

View File

@@ -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())

View File

@@ -27,7 +27,7 @@ struct TextFileContentView: View {
.textEditorStyle(.plain)
.foregroundStyle(.primary)
} else {
Image(systemSymbol: .docText)
Image(systemSymbol: .textDocument)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 150)
@@ -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)")
}
}

View File

@@ -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 {

View File

@@ -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
}
}

View 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) }
}
}

View File

@@ -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 }
}
}

View File

@@ -1,5 +1,5 @@
import SFSafeSymbols
import SwiftUICore
import SwiftUI
enum IssueStatus {
case nominal

View 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)
}
}

View File

@@ -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

View 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)
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -16,7 +16,7 @@ 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)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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):
@@ -160,8 +160,8 @@ private struct FileButtonView: View {
var body: some View {
HStack {
LabelEditingView(label: content.label)
Button("\(content.file?.id ?? "Select file")", action: { showFileSelectionSheet = true })
LabelEditingView(label: $content.label)
Button("\(content.file?.identifier ?? "Select file")", action: { showFileSelectionSheet = true })
OptionalTextField("", text: $content.downloadedFileName, prompt: "Downloaded file name")
.textFieldStyle(.roundedBorder)
}
@@ -178,7 +178,7 @@ private struct UrlButtonView: View {
var body: some View {
HStack {
LabelEditingView(label: content.label)
LabelEditingView(label: $content.label)
TextField("", text: $content.url, prompt: Text("URL"))
.textFieldStyle(.roundedBorder)
}
@@ -192,7 +192,7 @@ private struct EventButtonView: View {
var body: some View {
HStack {
LabelEditingView(label: content.label)
LabelEditingView(label: $content.label)
TextField("", text: $content.event, prompt: Text("Javascript"))
.textFieldStyle(.roundedBorder)
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
import SFSafeSymbols
struct InsertableGallery: View, InsertableCommandView {
static let title = "Gallery"
static let sheetTitle = "Insert an image gallery"
static let icon: SFSymbol = .photoStack
final class Model: InsertableCommandModel {
@Published
var images: [FileResource] = []
var isReady: Bool {
!images.isEmpty
}
init() {
}
var command: String? {
guard !images.isEmpty else {
return nil
}
return (
["```\(GalleryBlock.blockId)"] +
images.map { $0.identifier } +
["```"]
).joined(separator: "\n")
}
}
@Environment(\.colorScheme)
private var colorScheme
@ObservedObject
private var model: Model
@State
private var showImagePicker = false
init(model: Model) {
self.model = model
}
var body: some View {
VStack(spacing: 2) {
ScrollView(.horizontal) {
HStack(alignment: .center, spacing: 8) {
ForEach(model.images) { image in
PostImageView(image: image)
}
}
}
Button("Select images", action: { showImagePicker = true })
.padding(.vertical, 2)
}
.sheet(isPresented: $showImagePicker) {
MultiFileSelectionView(
selectedFiles: $model.images,
allowedType: .image)
}
}
}

View File

@@ -24,9 +24,9 @@ struct InsertableImage: View, InsertableCommandView {
return nil
}
guard let caption else {
return "![image](\(selectedImage.id))"
return "![image](\(selectedImage.identifier))"
}
return "![image](\(selectedImage.id);\(caption))"
return "![image](\(selectedImage.identifier);\(caption))"
}
}

View File

@@ -35,47 +35,15 @@ struct InsertableLabels: View, InsertableCommandView {
}
}
@Environment(\.colorScheme)
private var colorScheme
@ObservedObject
private var model: Model
init(model: Model) {
self.model = model
}
var body: some View {
VStack(spacing: 2) {
ForEach(model.labels, id: \.icon) { label in
HStack {
Button(action: { remove(label) }) {
Image(systemSymbol: .minusCircleFill)
.foregroundStyle(.red)
}
.buttonStyle(.plain)
LabelEditingView(label: label)
}
.padding(.vertical, 2)
.padding(.horizontal, 8)
.background(colorScheme == .light ? Color.white : Color.black)
.cornerRadius(8)
}
Button("Add", action: addLabel)
.padding(.vertical, 2)
}
LabelCreationView(labels: $model.labels)
}
private func addLabel() {
model.labels.append(.init(icon: .clockFill, value: "Value"))
}
private func remove(_ label: ContentLabel) {
guard let index = model.labels.firstIndex(of: label) else {
return
}
model.labels.remove(at: index)
}
}

Some files were not shown because too many files have changed in this diff Show More