Compare commits

..

28 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
124 changed files with 3565 additions and 381 deletions

View File

@@ -7,6 +7,9 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; E20BCC972D53454C00B8DBEB /* StorageItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCC962D53454500B8DBEB /* StorageItem.swift */; };
E20BCC992D53597D00B8DBEB /* SaveState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCC982D53597D00B8DBEB /* SaveState.swift */; }; E20BCC992D53597D00B8DBEB /* SaveState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCC982D53597D00B8DBEB /* SaveState.swift */; };
E20BCC9B2D535C3500B8DBEB /* ChangeObservableItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCC9A2D535C3100B8DBEB /* ChangeObservableItem.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 */; }; 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 */; }; 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 */; }; 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 */; }; E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990142D0E2B74009F8D77 /* ItemSelectionView.swift */; };
E22990192D0E3546009F8D77 /* ItemReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990182D0E3546009F8D77 /* ItemReference.swift */; }; E22990192D0E3546009F8D77 /* ItemReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990182D0E3546009F8D77 /* ItemReference.swift */; };
E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229901D2D0E4362009F8D77 /* LocalizedItem.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 */; }; E25DA5952D023BD100AEF16D /* PageSettingsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5942D023BCC00AEF16D /* PageSettingsDetailView.swift */; };
E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5982D02401A00AEF16D /* PageGenerator.swift */; }; E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5982D02401A00AEF16D /* PageGenerator.swift */; };
E25DA59B2D024A2B00AEF16D /* DateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA59A2D024A2900AEF16D /* DateItem.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 */; }; E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D311F2D0320E20051B7F4 /* ContentLabels.swift */; };
E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31212D0363FA0051B7F4 /* ContentButtons.swift */; }; E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31212D0363FA0051B7F4 /* ContentButtons.swift */; };
E29D31242D0366860051B7F4 /* TagList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31232D0366820051B7F4 /* TagList.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 */; }; E2A37D2B2CED2CC30000979F /* TagDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2A2CED2CC30000979F /* TagDetailView.swift */; };
E2A37D2D2CED2EF10000979F /* OptionalTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */; }; E2A37D2D2CED2EF10000979F /* OptionalTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A37D2C2CED2EEE0000979F /* OptionalTextField.swift */; };
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A9CB7D2C7BCF2A005C89CC /* Page.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 */; }; E2B482002D5D1136005C309D /* Vapor in Frameworks */ = {isa = PBXBuildFile; productRef = E2B481FF2D5D1136005C309D /* Vapor */; };
E2B482032D5D1331005C309D /* WebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482022D5D132D005C309D /* WebServer.swift */; }; E2B482032D5D1331005C309D /* WebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482022D5D132D005C309D /* WebServer.swift */; };
E2B482052D5E7D4A005C309D /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482042D5E7D4A005C309D /* WebView.swift */; }; E2B482052D5E7D4A005C309D /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482042D5E7D4A005C309D /* WebView.swift */; };
@@ -194,6 +210,20 @@
E2BF1BC82D6FC880003089F1 /* Insert+Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */; }; E2BF1BC82D6FC880003089F1 /* Insert+Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */; };
E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BC92D70EDF3003089F1 /* TagPropertyView.swift */; }; E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BC92D70EDF3003089F1 /* TagPropertyView.swift */; };
E2BF1BCC2D70EE59003089F1 /* TagPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BF1BCB2D70EE55003089F1 /* TagPickerView.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 */; }; E2DD04742C276F31003BFF1F /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DD04732C276F31003BFF1F /* MainView.swift */; };
E2DD047A2C276F32003BFF1F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD04792C276F32003BFF1F /* Assets.xcassets */; }; E2DD047A2C276F32003BFF1F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD04792C276F32003BFF1F /* Assets.xcassets */; };
E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFA2CA4A6570019C2AF /* Content.swift */; }; E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFA2CA4A6570019C2AF /* Content.swift */; };
@@ -207,6 +237,9 @@
E2F3B3982DC54F9400CFA712 /* ChangeObservingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3972DC54F8600CFA712 /* ChangeObservingItem.swift */; }; E2F3B3982DC54F9400CFA712 /* ChangeObservingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B3972DC54F8600CFA712 /* ChangeObservingItem.swift */; };
E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B39B2DC5542E00CFA712 /* LabelEditingView.swift */; }; E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B39B2DC5542E00CFA712 /* LabelEditingView.swift */; };
E2F3B39E2DC55B1C00CFA712 /* LabelCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F3B39D2DC55B1C00CFA712 /* LabelCreationView.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 */; }; E2FD1D0D2D2DBBA600B48627 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D0C2D2DBBA100B48627 /* LinkPreview.swift */; };
E2FD1D192D2DC4F500B48627 /* LoadingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D182D2DC4F500B48627 /* LoadingContext.swift */; }; E2FD1D192D2DC4F500B48627 /* LoadingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D182D2DC4F500B48627 /* LoadingContext.swift */; };
E2FD1D1B2D2DC63800B48627 /* LinkPreviewDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */; }; E2FD1D1B2D2DC63800B48627 /* LinkPreviewDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */; };
@@ -234,7 +267,6 @@
E2FD1D5A2D477AB200B48627 /* InsertableItemsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D592D477AB200B48627 /* InsertableItemsView.swift */; }; E2FD1D5A2D477AB200B48627 /* InsertableItemsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D592D477AB200B48627 /* InsertableItemsView.swift */; };
E2FD1D5C2D47EEB800B48627 /* LinkedPageTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D5B2D47EEB800B48627 /* LinkedPageTagView.swift */; }; E2FD1D5C2D47EEB800B48627 /* LinkedPageTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D5B2D47EEB800B48627 /* LinkedPageTagView.swift */; };
E2FD1D5E2D47EED200B48627 /* PostImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D5D2D47EED200B48627 /* PostImageView.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 */; }; E2FD1D642D47EF4200B48627 /* DetailListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D632D47EF4200B48627 /* DetailListItem.swift */; };
E2FD1D682D483CCF00B48627 /* Insert+Buttons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D672D483CCA00B48627 /* Insert+Buttons.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 */; }; E2FE0EE62D15A0B5002963B7 /* GenerationResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0EE52D15A0B1002963B7 /* GenerationResults.swift */; };
@@ -295,6 +327,9 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference 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>"; }; 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>"; }; 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>"; }; E20BCC9A2D535C3100B8DBEB /* ChangeObservableItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeObservableItem.swift; sourceTree = "<group>"; };
@@ -314,6 +349,12 @@
E21850322CFAFA200090B18B /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; E229901D2D0E4362009F8D77 /* LocalizedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedItem.swift; sourceTree = "<group>"; };
@@ -373,6 +414,9 @@
E25DA5942D023BCC00AEF16D /* PageSettingsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettingsDetailView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; E29D31232D0366820051B7F4 /* TagList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagList.swift; sourceTree = "<group>"; };
@@ -450,6 +494,9 @@
E2A37D2A2CED2CC30000979F /* TagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; E2B482082D5E7F4C005C309D /* WebsitePreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsitePreviewSheet.swift; sourceTree = "<group>"; };
@@ -473,6 +520,20 @@
E2BF1BC72D6FC87C003089F1 /* Insert+Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Insert+Link.swift"; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; E2DD04792C276F32003BFF1F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -490,6 +551,9 @@
E2F3B3972DC54F8600CFA712 /* ChangeObservingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeObservingItem.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewDetailView.swift; sourceTree = "<group>"; };
@@ -516,7 +580,6 @@
E2FD1D592D477AB200B48627 /* InsertableItemsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertableItemsView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; E2FE0EE52D15A0B1002963B7 /* GenerationResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationResults.swift; sourceTree = "<group>"; };
@@ -581,6 +644,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E224E0E52E56528F0031C2B0 /* BinaryCodable in Frameworks */,
E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */, E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */,
E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */, E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */,
E2FD1D522D4644B400B48627 /* SVGView in Frameworks */, E2FD1D522D4644B400B48627 /* SVGView in Frameworks */,
@@ -599,6 +663,8 @@
E20BCCA02D53985500B8DBEB /* Generation */ = { E20BCCA02D53985500B8DBEB /* Generation */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E2F3B3A52DC7F60E00CFA712 /* GenerationIssuesActionView.swift */,
E2F3B3A32DC7DC1F00CFA712 /* GenerationIssuesView.swift */,
E20BCCAE2D53F4A500B8DBEB /* GenerationStringIssuesView.swift */, E20BCCAE2D53F4A500B8DBEB /* GenerationStringIssuesView.swift */,
E20BCCAC2D53F48100B8DBEB /* IssueStatus.swift */, E20BCCAC2D53F48100B8DBEB /* IssueStatus.swift */,
E20BCCAA2D53B85300B8DBEB /* GenerationResultsIssueView.swift */, E20BCCAA2D53B85300B8DBEB /* GenerationResultsIssueView.swift */,
@@ -679,6 +745,26 @@
path = Mock; path = Mock;
sourceTree = "<group>"; 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 */ = { E229901A2D0E3F09009F8D77 /* Item */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -697,6 +783,7 @@
E25DA53B2D0042EA00AEF16D /* Settings */ = { E25DA53B2D0042EA00AEF16D /* Settings */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E29A577D2E9E444000B19DA3 /* ToolSettings.swift */,
E2FD1D2D2D37180600B48627 /* GeneralSettings.swift */, E2FD1D2D2D37180600B48627 /* GeneralSettings.swift */,
E2FE0F392D2B3E4E002963B7 /* AudioPlayerSettings.swift */, E2FE0F392D2B3E4E002963B7 /* AudioPlayerSettings.swift */,
E2FE0F6D2D2D3685002963B7 /* LocalizedAudioPlayerSettings.swift */, E2FE0F6D2D2D3685002963B7 /* LocalizedAudioPlayerSettings.swift */,
@@ -724,7 +811,9 @@
E22990232D0EDBD0009F8D77 /* HeaderElement.swift */, E22990232D0EDBD0009F8D77 /* HeaderElement.swift */,
E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */, E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */,
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */, E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
E2DBA3B42E590B1E00F1E143 /* Images */,
E22990412D107A94009F8D77 /* ImageVersion.swift */, E22990412D107A94009F8D77 /* ImageVersion.swift */,
E2DBA3B22E58FB6900F1E143 /* StatisticsFileGenerator.swift */,
E2FE0F182D2723E3002963B7 /* ImageSet.swift */, E2FE0F182D2723E3002963B7 /* ImageSet.swift */,
); );
path = Generator; path = Generator;
@@ -829,11 +918,13 @@
E2A21C372CB9A4F10060935B /* Generic */ = { E2A21C372CB9A4F10060935B /* Generic */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E2F3B3A12DC769BF00CFA712 /* ColoredButton.swift */,
E229902F2D0F75CF009F8D77 /* BoolPropertyView.swift */, E229902F2D0F75CF009F8D77 /* BoolPropertyView.swift */,
E22990312D0F7678009F8D77 /* DatePropertyView.swift */, E22990312D0F7678009F8D77 /* DatePropertyView.swift */,
E29D312F2D03A2BD0051B7F4 /* DescriptionField.swift */, E29D312F2D03A2BD0051B7F4 /* DescriptionField.swift */,
E22990292D0F5A10009F8D77 /* DetailTitle.swift */, E22990292D0F5A10009F8D77 /* DetailTitle.swift */,
E22990252D0F5822009F8D77 /* FilePropertyView.swift */, E22990252D0F5822009F8D77 /* FilePropertyView.swift */,
E2DBA3BB2E5CC18000F1E143 /* FilesPropertyView.swift */,
E2A21C0F2CB18B390060935B /* FlowHStack.swift */, E2A21C0F2CB18B390060935B /* FlowHStack.swift */,
E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */, E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */,
E22990392D0F7E44009F8D77 /* GenericPropertyView.swift */, E22990392D0F7E44009F8D77 /* GenericPropertyView.swift */,
@@ -1008,6 +1099,9 @@
E2FD1D3E2D46404900B48627 /* PostLabelsView.swift */, E2FD1D3E2D46404900B48627 /* PostLabelsView.swift */,
E29D31502D0616890051B7F4 /* PostListView.swift */, E29D31502D0616890051B7F4 /* PostListView.swift */,
E218502A2CF790AC0090B18B /* PostContentView.swift */, E218502A2CF790AC0090B18B /* PostContentView.swift */,
E2039A182E0002C100305538 /* PostTextView.swift */,
E2039A162E00027300305538 /* PostTitleView.swift */,
E2039A142E0001B200305538 /* PostImagesView.swift */,
E21850262CF3B42D0090B18B /* PostDetailView.swift */, E21850262CF3B42D0090B18B /* PostDetailView.swift */,
E29D313E2D04822C0051B7F4 /* AddPostView.swift */, E29D313E2D04822C0051B7F4 /* AddPostView.swift */,
E21850222CF10C840090B18B /* TagSelectionView.swift */, E21850222CF10C840090B18B /* TagSelectionView.swift */,
@@ -1015,7 +1109,6 @@
E2A21C072CB17B810060935B /* TagView.swift */, E2A21C072CB17B810060935B /* TagView.swift */,
E29D31312D03B5610051B7F4 /* LocalizedPostDetailView.swift */, E29D31312D03B5610051B7F4 /* LocalizedPostDetailView.swift */,
E2FD1D5D2D47EED200B48627 /* PostImageView.swift */, E2FD1D5D2D47EED200B48627 /* PostImageView.swift */,
E2FD1D5F2D47EEEF00B48627 /* LocalizedPostContentView.swift */,
E2FD1D5B2D47EEB800B48627 /* LinkedPageTagView.swift */, E2FD1D5B2D47EEB800B48627 /* LinkedPageTagView.swift */,
); );
path = Posts; path = Posts;
@@ -1024,6 +1117,8 @@
E2B85F552C4BD0AD0047CD0C /* Extensions */ = { E2B85F552C4BD0AD0047CD0C /* Extensions */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E26C300E2E634B3A00FEB26D /* TimeInterval+Extensions.swift */,
E2ADC02D2E57CC6500B4FF88 /* Double+Rounded.swift */,
E2FE0F1F2D29A709002963B7 /* Array+Remove.swift */, E2FE0F1F2D29A709002963B7 /* Array+Remove.swift */,
E2FE0EE72D16D4A3002963B7 /* ConvertThrowing.swift */, E2FE0EE72D16D4A3002963B7 /* ConvertThrowing.swift */,
E25DA5182CFF035200AEF16D /* Array+Split.swift */, E25DA5182CFF035200AEF16D /* Array+Split.swift */,
@@ -1040,6 +1135,27 @@
path = Extensions; path = Extensions;
sourceTree = "<group>"; 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 = { E2DD04672C276F31003BFF1F = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -1060,6 +1176,7 @@
E2DD04722C276F31003BFF1F /* CHDataManagement */ = { E2DD04722C276F31003BFF1F /* CHDataManagement */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E224E0D72E55074E0031C2B0 /* Workouts */,
E2B482162D63AF6F005C309D /* Notifications */, E2B482162D63AF6F005C309D /* Notifications */,
E2B4820E2D5E9FF0005C309D /* Push */, E2B4820E2D5E9FF0005C309D /* Push */,
E2B482012D5D1325005C309D /* Server */, E2B482012D5D1325005C309D /* Server */,
@@ -1120,6 +1237,7 @@
E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */, E2EC1FB32DC0FA6D00C41784 /* Insert+Route.swift */,
E2FD1D552D46CED500B48627 /* Insert+Labels.swift */, E2FD1D552D46CED500B48627 /* Insert+Labels.swift */,
E2F3B3842DC49B4400CFA712 /* Insert+Gallery.swift */, E2F3B3842DC49B4400CFA712 /* Insert+Gallery.swift */,
E2720B872DF38BB200FDB543 /* Insert+Video.swift */,
); );
path = Commands; path = Commands;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -1161,6 +1279,7 @@
E2FE0F342D2B27E6002963B7 /* Blocks */ = { E2FE0F342D2B27E6002963B7 /* Blocks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E2DBA3B02E58F57800F1E143 /* WorkoutBlock.swift */,
E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */, E2F3B3822DC496C800CFA712 /* GalleryBlock.swift */,
E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */, E2EC1FAF2DC0D7D600C41784 /* RouteBlock.swift */,
E2B482212D676BEB005C309D /* PhoneScreensBlock.swift */, E2B482212D676BEB005C309D /* PhoneScreensBlock.swift */,
@@ -1259,6 +1378,7 @@
E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */, E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */,
E2FD1D512D4644B400B48627 /* SVGView */, E2FD1D512D4644B400B48627 /* SVGView */,
E2B481FF2D5D1136005C309D /* Vapor */, E2B481FF2D5D1136005C309D /* Vapor */,
E224E0E42E56528F0031C2B0 /* BinaryCodable */,
); );
productName = CHDataManagement; productName = CHDataManagement;
productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */; productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */;
@@ -1272,7 +1392,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1540; LastSwiftUpdateCheck = 1540;
LastUpgradeCheck = 1630; LastUpgradeCheck = 2600;
TargetAttributes = { TargetAttributes = {
E2DD046F2C276F31003BFF1F = { E2DD046F2C276F31003BFF1F = {
CreatedOnToolsVersion = 15.4; CreatedOnToolsVersion = 15.4;
@@ -1298,6 +1418,7 @@
E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */, E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
E2FD1D502D4644B400B48627 /* XCRemoteSwiftPackageReference "SVGView" */, E2FD1D502D4644B400B48627 /* XCRemoteSwiftPackageReference "SVGView" */,
E2B481FE2D5D1136005C309D /* XCRemoteSwiftPackageReference "vapor" */, E2B481FE2D5D1136005C309D /* XCRemoteSwiftPackageReference "vapor" */,
E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */,
); );
productRefGroup = E2DD04712C276F31003BFF1F /* Products */; productRefGroup = E2DD04712C276F31003BFF1F /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -1327,6 +1448,7 @@
files = ( files = (
E2FD1D562D46CED900B48627 /* Insert+Labels.swift in Sources */, E2FD1D562D46CED900B48627 /* Insert+Labels.swift in Sources */,
E29D31242D0366860051B7F4 /* TagList.swift in Sources */, E29D31242D0366860051B7F4 /* TagList.swift in Sources */,
E2ADC02E2E57CC6900B4FF88 /* Double+Rounded.swift in Sources */,
E22990282D0F596C009F8D77 /* IntegerPropertyView.swift in Sources */, E22990282D0F596C009F8D77 /* IntegerPropertyView.swift in Sources */,
E2FD1D1D2D2DE31800B48627 /* ItemType.swift in Sources */, E2FD1D1D2D2DE31800B48627 /* ItemType.swift in Sources */,
E2FE0F482D2BC7D1002963B7 /* MarkdownProcessor.swift in Sources */, E2FE0F482D2BC7D1002963B7 /* MarkdownProcessor.swift in Sources */,
@@ -1340,6 +1462,7 @@
E29D317D2D086AB00051B7F4 /* Int+Random.swift in Sources */, E29D317D2D086AB00051B7F4 /* Int+Random.swift in Sources */,
E2BF1BC62D6B16FF003089F1 /* HeadlineLink.swift in Sources */, E2BF1BC62D6B16FF003089F1 /* HeadlineLink.swift in Sources */,
E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */, E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */,
E2039A192E0002C500305538 /* PostTextView.swift in Sources */,
E2521E042D51796000C56662 /* StorageStatusView.swift in Sources */, E2521E042D51796000C56662 /* StorageStatusView.swift in Sources */,
E29D313B2D04464A0051B7F4 /* LocalizedTagDetailView.swift in Sources */, E29D313B2D04464A0051B7F4 /* LocalizedTagDetailView.swift in Sources */,
E2FE0F552D2BCFC4002963B7 /* ContentBlock.swift in Sources */, E2FE0F552D2BCFC4002963B7 /* ContentBlock.swift in Sources */,
@@ -1357,6 +1480,7 @@
E229902E2D0F7280009F8D77 /* IdPropertyView.swift in Sources */, E229902E2D0F7280009F8D77 /* IdPropertyView.swift in Sources */,
E2FE0F462D2BC777002963B7 /* MarkdownImageProcessor.swift in Sources */, E2FE0F462D2BC777002963B7 /* MarkdownImageProcessor.swift in Sources */,
E29D31AD2D0DA5360051B7F4 /* AudioPlayerIcons.swift in Sources */, E29D31AD2D0DA5360051B7F4 /* AudioPlayerIcons.swift in Sources */,
E2DBA3B32E58FB7500F1E143 /* StatisticsFileGenerator.swift in Sources */,
E2FD1D5A2D477AB200B48627 /* InsertableItemsView.swift in Sources */, E2FD1D5A2D477AB200B48627 /* InsertableItemsView.swift in Sources */,
E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */, E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */,
E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */, E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */,
@@ -1369,6 +1493,7 @@
E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */, E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */,
E2B85F452C429ED60047CD0C /* ImageGallery.swift in Sources */, E2B85F452C429ED60047CD0C /* ImageGallery.swift in Sources */,
E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */, E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */,
E2039A152E0001B700305538 /* PostImagesView.swift in Sources */,
E29D31852D0AE8EE0051B7F4 /* KnownHeaderElement.swift in Sources */, E29D31852D0AE8EE0051B7F4 /* KnownHeaderElement.swift in Sources */,
E25DA51B2CFF08BB00AEF16D /* PostFeedPageNavigation.swift in Sources */, E25DA51B2CFF08BB00AEF16D /* PostFeedPageNavigation.swift in Sources */,
E22990422D107A95009F8D77 /* ImageVersion.swift in Sources */, E22990422D107A95009F8D77 /* ImageVersion.swift in Sources */,
@@ -1378,6 +1503,7 @@
E229904E2D13535C009F8D77 /* SecurityBookmark.swift in Sources */, E229904E2D13535C009F8D77 /* SecurityBookmark.swift in Sources */,
E2FE0F6E2D2D3689002963B7 /* LocalizedAudioPlayerSettings.swift in Sources */, E2FE0F6E2D2D3689002963B7 /* LocalizedAudioPlayerSettings.swift in Sources */,
E2A21C082CB17B870060935B /* TagView.swift in Sources */, E2A21C082CB17B870060935B /* TagView.swift in Sources */,
E2DBA3B82E590BEE00F1E143 /* Image+Png.swift in Sources */,
E29D313D2D047C1B0051B7F4 /* LocalizedPageContentView.swift in Sources */, E29D313D2D047C1B0051B7F4 /* LocalizedPageContentView.swift in Sources */,
E2BF1BC82D6FC880003089F1 /* Insert+Link.swift in Sources */, E2BF1BC82D6FC880003089F1 /* Insert+Link.swift in Sources */,
E2FE0F242D2A8C21002963B7 /* TagDisplayView.swift in Sources */, E2FE0F242D2A8C21002963B7 /* TagDisplayView.swift in Sources */,
@@ -1398,6 +1524,7 @@
E2FE0F4F2D2BCD80002963B7 /* TagLinkCommand.swift in Sources */, E2FE0F4F2D2BCD80002963B7 /* TagLinkCommand.swift in Sources */,
E2FD1D302D37196C00B48627 /* GeneralSettingsDetailView.swift in Sources */, E2FD1D302D37196C00B48627 /* GeneralSettingsDetailView.swift in Sources */,
E22990342D0F77E9009F8D77 /* PagePropertyView.swift in Sources */, E22990342D0F77E9009F8D77 /* PagePropertyView.swift in Sources */,
E224E0E92E5668470031C2B0 /* Time+String.swift in Sources */,
E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */, E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */,
E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */, E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */,
E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */, E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */,
@@ -1405,6 +1532,7 @@
E229902C2D0F6FC6009F8D77 /* LocalizedItemId.swift in Sources */, E229902C2D0F6FC6009F8D77 /* LocalizedItemId.swift in Sources */,
E25DA5952D023BD100AEF16D /* PageSettingsDetailView.swift in Sources */, E25DA5952D023BD100AEF16D /* PageSettingsDetailView.swift in Sources */,
E21850092CEE01C30090B18B /* PagePickerView.swift in Sources */, E21850092CEE01C30090B18B /* PagePickerView.swift in Sources */,
E2DBA3D12E61E5FF00F1E143 /* Point.swift in Sources */,
E29D31492D0489BB0051B7F4 /* AddFileView.swift in Sources */, E29D31492D0489BB0051B7F4 /* AddFileView.swift in Sources */,
E2B482202D670753005C309D /* WallpaperSlider.swift in Sources */, E2B482202D670753005C309D /* WallpaperSlider.swift in Sources */,
E29D312E2D03A0D70051B7F4 /* LocalizedPageDetailView.swift in Sources */, E29D312E2D03A0D70051B7F4 /* LocalizedPageDetailView.swift in Sources */,
@@ -1416,21 +1544,26 @@
E2B4821A2D63AFF6005C309D /* NotificationSender.swift in Sources */, E2B4821A2D63AFF6005C309D /* NotificationSender.swift in Sources */,
E2FE0F3A2D2B3E4F002963B7 /* AudioPlayerSettings.swift in Sources */, E2FE0F3A2D2B3E4F002963B7 /* AudioPlayerSettings.swift in Sources */,
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */, E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */,
E2DBA3BC2E5CC18500F1E143 /* FilesPropertyView.swift in Sources */,
E2FE0F092D2689F0002963B7 /* TagPageGeneratorSource.swift in Sources */, E2FE0F092D2689F0002963B7 /* TagPageGeneratorSource.swift in Sources */,
E22990302D0F75DE009F8D77 /* BoolPropertyView.swift in Sources */, E22990302D0F75DE009F8D77 /* BoolPropertyView.swift in Sources */,
E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */, E229901E2D0E4364009F8D77 /* LocalizedItem.swift in Sources */,
E29D31262D0370A80051B7F4 /* VideoCommand+Option.swift in Sources */, E29D31262D0370A80051B7F4 /* VideoCommand+Option.swift in Sources */,
E2FE0EF82D1D8110002963B7 /* IconCommand.swift in Sources */, E2FE0EF82D1D8110002963B7 /* IconCommand.swift in Sources */,
E2039A172E00027600305538 /* PostTitleView.swift in Sources */,
E2F3B39E2DC55B1C00CFA712 /* LabelCreationView.swift in Sources */, E2F3B39E2DC55B1C00CFA712 /* LabelCreationView.swift in Sources */,
E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */, E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */,
E22990242D0EDBD0009F8D77 /* HeaderElement.swift in Sources */, E22990242D0EDBD0009F8D77 /* HeaderElement.swift in Sources */,
E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */, E2BF1BCA2D70EDF8003089F1 /* TagPropertyView.swift in Sources */,
E2DBA3C92E5E603300F1E143 /* DataRanges.swift in Sources */,
E2ADC02C2E5795F300B4FF88 /* ElevationGraph.swift in Sources */,
E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */, E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */,
E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */, E2F3B39C2DC5542E00CFA712 /* LabelEditingView.swift in Sources */,
E2FE0F662D2C3B3A002963B7 /* LabelsBlock.swift in Sources */, E2FE0F662D2C3B3A002963B7 /* LabelsBlock.swift in Sources */,
E20BCCAF2D53F4A500B8DBEB /* GenerationStringIssuesView.swift in Sources */, E20BCCAF2D53F4A500B8DBEB /* GenerationStringIssuesView.swift in Sources */,
E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */, E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */,
E29D31432D0488960051B7F4 /* MainContentView.swift in Sources */, E29D31432D0488960051B7F4 /* MainContentView.swift in Sources */,
E2DBA3CF2E5F771F00F1E143 /* Double+Arithmetic.swift in Sources */,
E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */, E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */,
E2B4820D2D5E811E005C309D /* TryFilesMiddleware.swift in Sources */, E2B4820D2D5E811E005C309D /* TryFilesMiddleware.swift in Sources */,
E20BCC9F2D53851400B8DBEB /* SelectableListItem.swift in Sources */, E20BCC9F2D53851400B8DBEB /* SelectableListItem.swift in Sources */,
@@ -1441,19 +1574,24 @@
E2FD1D462D46428100B48627 /* PageIconView.swift in Sources */, E2FD1D462D46428100B48627 /* PageIconView.swift in Sources */,
E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */, E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */,
E2FE0F172D2698D5002963B7 /* LocalizedPageId.swift in Sources */, E2FE0F172D2698D5002963B7 /* LocalizedPageId.swift in Sources */,
E224E0E72E5664AF0031C2B0 /* RoutePreviewView.swift in Sources */,
E2FD1D2E2D37180900B48627 /* GeneralSettings.swift in Sources */, E2FD1D2E2D37180900B48627 /* GeneralSettings.swift in Sources */,
E2FD1D542D46577700B48627 /* HtmlProducer.swift in Sources */, E2FD1D542D46577700B48627 /* HtmlProducer.swift in Sources */,
E224E0DE2E5651DB0031C2B0 /* Sequence+Median.swift in Sources */,
E2FE0F0D2D268A09002963B7 /* PostListPageGeneratorSource.swift in Sources */, E2FE0F0D2D268A09002963B7 /* PostListPageGeneratorSource.swift in Sources */,
E2521E002D50BB6E00C56662 /* ItemLinkResults.swift in Sources */, E2521E002D50BB6E00C56662 /* ItemLinkResults.swift in Sources */,
E2FE0F402D2B45D3002963B7 /* SwiftBlock.swift in Sources */, E2FE0F402D2B45D3002963B7 /* SwiftBlock.swift in Sources */,
E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */, E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */,
E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */, E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */,
E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */, E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */,
E2F3B3A22DC769C300CFA712 /* ColoredButton.swift in Sources */,
E2DBA3D32E61F70000F1E143 /* CLLocation+Extensions.swift in Sources */,
E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */, E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */,
E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */, E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */,
E2FE0F422D2B4821002963B7 /* OtherCodeBlock.swift in Sources */, E2FE0F422D2B4821002963B7 /* OtherCodeBlock.swift in Sources */,
E21850332CFAFA2F0090B18B /* Settings.swift in Sources */, E21850332CFAFA2F0090B18B /* Settings.swift in Sources */,
E29D31892D0AED1F0051B7F4 /* ModelViewer.swift in Sources */, E29D31892D0AED1F0051B7F4 /* ModelViewer.swift in Sources */,
E2DBA3BA2E5CBFAE00F1E143 /* Date+Days.swift in Sources */,
E2FD1D392D3BBED300B48627 /* InsertableView.swift in Sources */, E2FD1D392D3BBED300B48627 /* InsertableView.swift in Sources */,
E29D31412D04887F0051B7F4 /* SelectedDetailView.swift in Sources */, E29D31412D04887F0051B7F4 /* SelectedDetailView.swift in Sources */,
E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */, E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */,
@@ -1462,8 +1600,10 @@
E2FE0EEE2D1C22F3002963B7 /* MarkdownLinkProcessor.swift in Sources */, E2FE0EEE2D1C22F3002963B7 /* MarkdownLinkProcessor.swift in Sources */,
E2FE0F602D2C0422002963B7 /* VideoBlock.swift in Sources */, E2FE0F602D2C0422002963B7 /* VideoBlock.swift in Sources */,
E2B482032D5D1331005C309D /* WebServer.swift in Sources */, E2B482032D5D1331005C309D /* WebServer.swift in Sources */,
E2720B882DF38BB700FDB543 /* Insert+Video.swift in Sources */,
E2FE0F022D266FCB002963B7 /* LocalizedNavigationSettings.swift in Sources */, E2FE0F022D266FCB002963B7 /* LocalizedNavigationSettings.swift in Sources */,
E29D313F2D04822C0051B7F4 /* AddPostView.swift in Sources */, E29D313F2D04822C0051B7F4 /* AddPostView.swift in Sources */,
E224E0E22E5652680031C2B0 /* WorkoutData.swift in Sources */,
E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */, E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */,
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */, E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */,
E2FD1D682D483CCF00B48627 /* Insert+Buttons.swift in Sources */, E2FD1D682D483CCF00B48627 /* Insert+Buttons.swift in Sources */,
@@ -1485,6 +1625,7 @@
E2FD1D5C2D47EEB800B48627 /* LinkedPageTagView.swift in Sources */, E2FD1D5C2D47EEB800B48627 /* LinkedPageTagView.swift in Sources */,
E22990382D0F7B32009F8D77 /* OptionalImagePropertyView.swift in Sources */, E22990382D0F7B32009F8D77 /* OptionalImagePropertyView.swift in Sources */,
E2FE0F512D2BCDC8002963B7 /* ModelCommand.swift in Sources */, E2FE0F512D2BCDC8002963B7 /* ModelCommand.swift in Sources */,
E224E0D92E55075C0031C2B0 /* MapImageCreator.swift in Sources */,
E2FE0F592D2BCFE4002963B7 /* OrderedKeyBlockProcessor.swift in Sources */, E2FE0F592D2BCFE4002963B7 /* OrderedKeyBlockProcessor.swift in Sources */,
E2FE0EEC2D1C1253002963B7 /* MultiFileSelectionView.swift in Sources */, E2FE0EEC2D1C1253002963B7 /* MultiFileSelectionView.swift in Sources */,
E29D31B32D0DA6E80051B7F4 /* ButtonIcons.swift in Sources */, E29D31B32D0DA6E80051B7F4 /* ButtonIcons.swift in Sources */,
@@ -1492,13 +1633,16 @@
E21A57392D8C714000E9EBE3 /* File+Mock.swift in Sources */, E21A57392D8C714000E9EBE3 /* File+Mock.swift in Sources */,
E21A573A2D8C714000E9EBE3 /* Tag+Mock.swift in Sources */, E21A573A2D8C714000E9EBE3 /* Tag+Mock.swift in Sources */,
E21A573B2D8C714000E9EBE3 /* Post+Mock.swift in Sources */, E21A573B2D8C714000E9EBE3 /* Post+Mock.swift in Sources */,
E2DBA3CB2E5E603900F1E143 /* RangeInterval.swift in Sources */,
E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */, E21A573C2D8C714000E9EBE3 /* Page+Mock.swift in Sources */,
E21A573D2D8C714000E9EBE3 /* Content+Mock.swift in Sources */, E21A573D2D8C714000E9EBE3 /* Content+Mock.swift in Sources */,
E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */, E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */,
E224E0E02E5652180031C2B0 /* Locations+Sampled.swift in Sources */,
E2FD1D3F2D46405000B48627 /* PostLabelsView.swift in Sources */, E2FD1D3F2D46405000B48627 /* PostLabelsView.swift in Sources */,
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */, E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */,
E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */, E2F3B3832DC496CB00CFA712 /* GalleryBlock.swift in Sources */,
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */, E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */,
E2DBA3B12E58F57B00F1E143 /* WorkoutBlock.swift in Sources */,
E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */, E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */,
E2FE0F572D2BCFD4002963B7 /* BlockLineProcessor.swift in Sources */, E2FE0F572D2BCFD4002963B7 /* BlockLineProcessor.swift in Sources */,
E229904A2D10BB90009F8D77 /* SecurityScopeBookmark.swift in Sources */, E229904A2D10BB90009F8D77 /* SecurityScopeBookmark.swift in Sources */,
@@ -1522,6 +1666,7 @@
E2F3B3982DC54F9400CFA712 /* ChangeObservingItem.swift in Sources */, E2F3B3982DC54F9400CFA712 /* ChangeObservingItem.swift in Sources */,
E21850372CFCA55F0090B18B /* LocalizedPostSettings.swift in Sources */, E21850372CFCA55F0090B18B /* LocalizedPostSettings.swift in Sources */,
E2FD1D642D47EF4200B48627 /* DetailListItem.swift in Sources */, E2FD1D642D47EF4200B48627 /* DetailListItem.swift in Sources */,
E29A577E2E9E444800B19DA3 /* ToolSettings.swift in Sources */,
E2FE0F0B2D2689FF002963B7 /* FeedGeneratorSource.swift in Sources */, E2FE0F0B2D2689FF002963B7 /* FeedGeneratorSource.swift in Sources */,
E2DD04742C276F31003BFF1F /* MainView.swift in Sources */, E2DD04742C276F31003BFF1F /* MainView.swift in Sources */,
E20BCCAD2D53F48100B8DBEB /* IssueStatus.swift in Sources */, E20BCCAD2D53F48100B8DBEB /* IssueStatus.swift in Sources */,
@@ -1547,6 +1692,7 @@
E2FE0F0F2D268D4F002963B7 /* BoxCommand.swift in Sources */, E2FE0F0F2D268D4F002963B7 /* BoxCommand.swift in Sources */,
E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */, E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */,
E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */, E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */,
E26C300F2E634B3A00FEB26D /* TimeInterval+Extensions.swift in Sources */,
E20BCC9B2D535C3500B8DBEB /* ChangeObservableItem.swift in Sources */, E20BCC9B2D535C3500B8DBEB /* ChangeObservableItem.swift in Sources */,
E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */, E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */,
E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */, E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */,
@@ -1556,7 +1702,9 @@
E2FE0F6C2D2D335E002963B7 /* LocalizedPageSettingsView.swift in Sources */, E2FE0F6C2D2D335E002963B7 /* LocalizedPageSettingsView.swift in Sources */,
E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */, E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */,
E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */, E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */,
E2F3B3A42DC7DC2400CFA712 /* GenerationIssuesView.swift in Sources */,
E25DA56D2D00EBCF00AEF16D /* NavigationBarSettingsView.swift in Sources */, E25DA56D2D00EBCF00AEF16D /* NavigationBarSettingsView.swift in Sources */,
E2ADC02A2E5794AB00B4FF88 /* RouteOverview.swift in Sources */,
E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */, E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */,
E2FE0F642D2C2F4D002963B7 /* ButtonBlock.swift in Sources */, E2FE0F642D2C2F4D002963B7 /* ButtonBlock.swift in Sources */,
E2FD1D5E2D47EED200B48627 /* PostImageView.swift in Sources */, E2FD1D5E2D47EED200B48627 /* PostImageView.swift in Sources */,
@@ -1571,16 +1719,20 @@
E29D31AA2D0CEE3F0051B7F4 /* AudioPlayer.swift in Sources */, E29D31AA2D0CEE3F0051B7F4 /* AudioPlayer.swift in Sources */,
E2FE0F4B2D2BCCAA002963B7 /* MarkdownHeadlineProcessor.swift in Sources */, E2FE0F4B2D2BCCAA002963B7 /* MarkdownHeadlineProcessor.swift in Sources */,
E2FE0F532D2BCE17002963B7 /* SvgCommand.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 */, E2FE0F3E2D2B4225002963B7 /* AudioSettingsDetailView.swift in Sources */,
E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */, E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */,
E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */, E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */,
E2FD1D0D2D2DBBA600B48627 /* LinkPreview.swift in Sources */, E2FD1D0D2D2DBBA600B48627 /* LinkPreview.swift in Sources */,
E20BCC972D53454C00B8DBEB /* StorageItem.swift in Sources */, E20BCC972D53454C00B8DBEB /* StorageItem.swift in Sources */,
E22990362D0F79D2009F8D77 /* OptionalStringPropertyView.swift in Sources */, E22990362D0F79D2009F8D77 /* OptionalStringPropertyView.swift in Sources */,
E2FD1D602D47EEEF00B48627 /* LocalizedPostContentView.swift in Sources */,
E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */, E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */,
E2FD1D582D477A9400B48627 /* InsertableCommand.swift in Sources */, E2FD1D582D477A9400B48627 /* InsertableCommand.swift in Sources */,
E2EC1FB42DC0FA8700C41784 /* Insert+Route.swift in Sources */, E2EC1FB42DC0FA8700C41784 /* Insert+Route.swift in Sources */,
E2F3B3A62DC7F61600CFA712 /* GenerationIssuesActionView.swift in Sources */,
E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */, E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */,
E2FE0F222D2A84A0002963B7 /* VideoCommand.swift in Sources */, E2FE0F222D2A84A0002963B7 /* VideoCommand.swift in Sources */,
E2FE0F192D2723E3002963B7 /* ImageSet.swift in Sources */, E2FE0F192D2723E3002963B7 /* ImageSet.swift in Sources */,
@@ -1663,6 +1815,7 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@@ -1719,6 +1872,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
}; };
name = Release; name = Release;
@@ -1751,7 +1905,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.1; MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement; PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -1790,7 +1944,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.1; MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement; PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.CHDataManagement;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -1825,6 +1979,14 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference 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" */ = { E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kyle-n/HighlightedTextEditor"; repositoryURL = "https://github.com/kyle-n/HighlightedTextEditor";
@@ -1900,6 +2062,11 @@
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
E224E0E42E56528F0031C2B0 /* BinaryCodable */ = {
isa = XCSwiftPackageProductDependency;
package = E224E0E32E56528F0031C2B0 /* XCRemoteSwiftPackageReference "BinaryCodable" */;
productName = BinaryCodable;
};
E24252002C50E0A40029FF16 /* HighlightedTextEditor */ = { E24252002C50E0A40029FF16 /* HighlightedTextEditor */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */; package = E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */;

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "747e13d88856438f8013440b6d706faa50b8e06e8a370d5c6bbfaf192255f3ff", "originHash" : "f8a1ac1b6fd2d65b9edf0e288c06780ac6a71414f18592b869bb082fb8c7690d",
"pins" : [ "pins" : [
{ {
"identity" : "async-http-client", "identity" : "async-http-client",
@@ -19,6 +19,15 @@
"version" : "1.20.0" "version" : "1.20.0"
} }
}, },
{
"identity" : "binarycodable",
"kind" : "remoteSourceControl",
"location" : "https://github.com/christophhagen/BinaryCodable",
"state" : {
"revision" : "53f057050f3c78a1997ed0218337fd92d2eba2b5",
"version" : "3.1.1"
}
},
{ {
"identity" : "console-kit", "identity" : "console-kit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

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

@@ -37,7 +37,7 @@ struct GalleryBlock: BlockLineProcessor {
$0.imageSet(width: imageWidth, height: imageWidth, language: language) $0.imageSet(width: imageWidth, height: imageWidth, language: language)
} }
imageSets.forEach(results.require) imageSets.forEach(results.require)
let id = firstImage.id.replacingOccurrences(of: ".", with: "-") let id = firstImage.identifier.replacingOccurrences(of: ".", with: "-")
let gallery = ImageGallery(id: id, images: imageSets, standalone: true) let gallery = ImageGallery(id: id, images: imageSets, standalone: true)
results.require(footer: gallery.standaloneFooter) results.require(footer: gallery.standaloneFooter)
results.require(headers: .swiperJs, .swiperCss) results.require(headers: .swiperJs, .swiperCss)

View File

@@ -50,7 +50,7 @@ struct PhoneScreensBlock: OrderedKeyBlockProcessor {
} }
if key == .tall { if key == .tall {
if tall != nil { if tall != nil {
print("Another tall image: \(file.id)") print("Another tall image: \(file.identifier)")
invalid(markdown) invalid(markdown)
return "" return ""
} }
@@ -69,7 +69,7 @@ struct PhoneScreensBlock: OrderedKeyBlockProcessor {
} }
// key == .wide // key == .wide
if wide != nil { if wide != nil {
print("Another wide image: \(file.id)") print("Another wide image: \(file.identifier)")
invalid(markdown) invalid(markdown)
return "" return ""
} }

View File

@@ -59,7 +59,7 @@ struct RouteBlock: KeyedBlockProcessor {
return "" return ""
} }
guard let file = content.file(fileId) else { guard let file = content.file(fileId) else {
results.missing(file: imageId, source: "Route block") results.missing(file: fileId, source: "Route block")
return "" return ""
} }
results.used(file: image) results.used(file: image)

View File

@@ -183,10 +183,19 @@ extension VideoBlock {
var mimeType: String { var mimeType: String {
switch self { switch self {
case .h265, .h264: "video/mp4" case .h265: "video/mp4; codecs=\"hvc1\""
case .h264: "video/mp4; codecs=\"avc1\""
case .webm: "video/webm" 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 { struct Source {
@@ -258,7 +267,7 @@ extension VideoBlock.Option {
Note: The `preload` attribute is ignored if `autoplay` is present. 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 /// The author thinks that the browser should load the entire video when the page loads
case auto 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 return
} }
if findFile(withAbsolutePath: path) { if findFile(withAbsolutePath: path) {
// File marked as required
return return
} }
let fileId = path.dropBeforeLast("/") results.requiredOutput(path.withLeadingSlashRemoved, source: "HTML: \(source)")
if content.isValidIdForFile(fileId) {
results.missing(file: fileId, source: "HTML: \(source)") // let fileId = path.dropBeforeLast("/")
} else { // if content.isValidIdForFile(fileId) {
results.warning("Could not find file '\(path)' for \(source)") // results.missing(file: fileId, source: "HTML: \(source)")
} // } else {
// results.warning("Could not find file '\(path)' for \(source)")
// }
} }
private func findFile(withAbsolutePath absolutePath: String) -> Bool { private func findFile(withAbsolutePath absolutePath: String) -> Bool {
@@ -185,7 +188,7 @@ struct HtmlCommand: CommandProcessor {
results.missing(file: fileId, source: "HTML: \(source)") results.missing(file: fileId, source: "HTML: \(source)")
return 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) { private func findFileWith(relativePath: String, type: FileType, source: String) {

View File

@@ -30,8 +30,8 @@ struct VideoCommand: CommandProcessor {
} }
results.require(file: file) results.require(file: file)
guard let videoType = file.type.htmlType else { guard let videoType = file.videoType() else {
invalid(markdown) invalid("File \(file.identifier) has an unknown video type")
return "" return ""
} }

View File

@@ -44,6 +44,9 @@ final class ImageGenerator {
// MARK: Image operations // MARK: Image operations
func generate(version: ImageVersion) -> Bool { func generate(version: ImageVersion) -> Bool {
if version.image.type == .route {
return generateImageForRoute(version: version)
}
if version.type == .avif { if version.type == .avif {
if version.image.type == .gif { if version.image.type == .gif {
// Skip GIFs, since they can't be converted by avifenc // Skip GIFs, since they can't be converted by avifenc
@@ -56,21 +59,24 @@ final class ImageGenerator {
} }
return false return false
} }
guard let data = version.image.dataContent() else { 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 false
} }
return generate(version: version, data: data)
}
private func generate(version: ImageVersion, data: Data) -> Bool {
guard let originalImage = NSImage(data: data) else { 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 return false
} }
let representation = create(image: originalImage, width: CGFloat(version.maximumWidth), height: CGFloat(version.maximumHeight)) 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 { 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 return false
} }
@@ -109,6 +115,34 @@ final class ImageGenerator {
return representation 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 // MARK: Avif images
private func create(image: NSBitmapImageRep, type: FileType, quality: CGFloat) -> Data? { private func create(image: NSBitmapImageRep, type: FileType, quality: CGFloat) -> Data? {
@@ -139,19 +173,23 @@ final class ImageGenerator {
} }
private func createAvifUsingBash(version: ImageVersion) -> Bool { private func createAvifUsingBash(version: ImageVersion) -> Bool {
let baseVersion = ImageVersion( let baseVersion = ImageVersion(
image: version.image, image: version.image,
type: version.image.type, type: version.image.type,
maximumWidth: version.maximumWidth, maximumWidth: version.maximumWidth,
maximumHeight: version.maximumHeight) maximumHeight: version.maximumHeight)
let originalImagePath = storage.outputPath(to: baseVersion.outputPath)!.path() 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 generatedImagePath = storage.outputPath(to: version.outputPath)!.path()
let quality = Int(version.quality * 100) let quality = Int(version.quality * 100)
// TODO: Run in security scope
let process = Process() let process = Process()
process.launchPath = "/opt/homebrew/bin/avifenc" // Adjust based on installation process.launchPath = settings.tools.avifencPath
process.arguments = ["-q", "\(quality)", originalImagePath, generatedImagePath] process.arguments = ["-q", "\(quality)", imagePath, generatedImagePath]
let pipe = Pipe() let pipe = Pipe()
process.standardOutput = pipe process.standardOutput = pipe
@@ -161,7 +199,7 @@ final class ImageGenerator {
process.waitUntilExit() process.waitUntilExit()
if process.terminationStatus != 0 { 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 outputData = pipe.fileHandleForReading.readDataToEndOfFile()
let outputString = String(data: outputData, encoding: .utf8) ?? "" let outputString = String(data: outputData, encoding: .utf8) ?? ""
print(outputString) print(outputString)

View File

@@ -4,6 +4,8 @@ struct ImageSet: HtmlProducer {
let image: FileResource let image: FileResource
let type: FileType
let maxWidth: Int let maxWidth: Int
let maxHeight: Int let maxHeight: Int
@@ -14,8 +16,9 @@ struct ImageSet: HtmlProducer {
let extraAttributes: String 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.image = image
self.type = type ?? image.type
self.maxWidth = maxWidth self.maxWidth = maxWidth
self.maxHeight = maxHeight self.maxHeight = maxHeight
self.description = description self.description = description
@@ -24,8 +27,6 @@ struct ImageSet: HtmlProducer {
} }
var jobs: [ImageVersion] { var jobs: [ImageVersion] {
let type = image.type
let width2x = maxWidth * 2 let width2x = maxWidth * 2
let height2x = maxHeight * 2 let height2x = maxHeight * 2

View File

@@ -56,14 +56,14 @@ struct ImageVersion {
extension ImageVersion: Identifiable { extension ImageVersion: Identifiable {
var id: String { var id: String {
image.id + "-" + versionId image.identifier + "-" + versionId
} }
} }
extension ImageVersion: Equatable { extension ImageVersion: Equatable {
static func == (lhs: ImageVersion, rhs: ImageVersion) -> Bool { static func == (lhs: ImageVersion, rhs: ImageVersion) -> Bool {
lhs.image.id == rhs.image.id && lhs.image.identifier == rhs.image.identifier &&
lhs.maximumWidth == rhs.maximumWidth && lhs.maximumWidth == rhs.maximumWidth &&
lhs.maximumHeight == rhs.maximumHeight && lhs.maximumHeight == rhs.maximumHeight &&
lhs.type == rhs.type lhs.type == rhs.type
@@ -73,7 +73,7 @@ extension ImageVersion: Equatable {
extension ImageVersion: Hashable { extension ImageVersion: Hashable {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(image.id) hasher.combine(image.identifier)
hasher.combine(maximumWidth) hasher.combine(maximumWidth)
hasher.combine(maximumHeight) hasher.combine(maximumHeight)
hasher.combine(type) 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

@@ -33,7 +33,7 @@ final class PageGenerator {
language: language, results: results) language: language, results: results)
let rawPageContent: String 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 rawPageContent = existing
} else { } else {
rawPageContent = makeEmptyPageContent(in: language) rawPageContent = makeEmptyPageContent(in: language)

View File

@@ -161,7 +161,7 @@ final class TagOverviewGenerator {
return imageSet return imageSet
} }
content += RelatedPageLink( content += RelatedPageLink(
title: tag.title, title: tag.title,
description: description, description: description,
url: tag.url, url: tag.url,

View File

@@ -44,7 +44,7 @@ struct PostContentGenerator {
} }
private var postDescription: String { private var postDescription: String {
"content of post \(post.id) (\(language.shortText))" "content of post \(post.identifier) (\(language.shortText))"
} }
private func handleLink( private func handleLink(
@@ -54,9 +54,7 @@ struct PostContentGenerator {
let converter = MarkdownLinkProcessor(content: content, results: self, language: language) let converter = MarkdownLinkProcessor(content: content, results: self, language: language)
let markdownUrl = markdown.between("(", and: ")") let markdownUrl = markdown.between("(", and: ")")
let text = markdown.between("[", and: "]") let text = markdown.between("[", and: "]")
guard let url = converter.convert(markdownUrl: markdownUrl)?.url else { let url = converter.convert(markdownUrl: markdownUrl)?.url ?? markdownUrl
return text
}
return "<span class='link' onclick=\"location.href='\(url)'; event.stopPropagation();\">\(text)</span>" 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) images.forEach(source.results.require)
media = .images(images) media = .images(images)
} else if localized.hasVideos { } 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) localized.images.forEach(source.results.require)
} else { } else {
media = nil media = nil
@@ -95,7 +106,7 @@ final class PostListPageGenerator {
post: post).generate() post: post).generate()
return FeedEntryData( return FeedEntryData(
entryId: post.id, entryId: post.identifier,
title: localized.title, title: localized.title,
textAboveTitle: post.dateText(in: language), textAboveTitle: post.dateText(in: language),
link: linkUrl, link: linkUrl,

View File

@@ -49,12 +49,17 @@ final class GenerationResults: ObservableObject {
@Published @Published
var emptyPages: Set<LocalizedPageId> = [] var emptyPages: Set<LocalizedPageId> = []
/// The paths to the files in the output folder, without leading slashes
@Published @Published
var outputFiles: Set<String> = [] var outputFiles: Set<String> = []
@Published @Published
var unusedFilesInOutput: Set<String> = [] 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. The url redirects to install to prevent broken links.
@@ -126,6 +131,7 @@ final class GenerationResults: ObservableObject {
self.redirects = [:] self.redirects = [:]
self.outputFiles = [] self.outputFiles = []
self.unusedFilesInOutput = [] self.unusedFilesInOutput = []
self.requiredOutputFiles = []
} }
for result in cache.values { for result in cache.values {
result.reset() result.reset()
@@ -161,7 +167,7 @@ final class GenerationResults: ObservableObject {
update { self.unsavedOutputFiles = unsavedOutputFiles } update { self.unsavedOutputFiles = unsavedOutputFiles }
let emptyPages = cache.values.filter { $0.pageIsEmpty }.map { $0.itemId }.compactMap { id -> LocalizedPageId? in let emptyPages = cache.values.filter { $0.pageIsEmpty }.map { $0.itemId }.compactMap { id -> LocalizedPageId? in
guard case .page(let page) = id.itemType else { return nil } 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() }.asSet()
update { self.emptyPages = emptyPages } update { self.emptyPages = emptyPages }
let redirects = cache.values.compactMap { $0.redirect }.reduce(into: [:]) { $0[$1.originalUrl] = $1.newUrl } 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>) { 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 } 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> { private extension Dictionary where Value == Set<LocalizedItemId> {

View File

@@ -11,7 +11,7 @@ extension ImageToGenerate: Hashable {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(size) hasher.combine(size)
hasher.combine(image.id) hasher.combine(image.identifier)
} }
} }
@@ -94,6 +94,10 @@ final class PageGenerationResults: ObservableObject {
@Published @Published
private(set) var unsavedOutputFiles: [String: Set<ItemReference>] = [:] 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 pageIsEmpty: Bool
private(set) var redirect: (originalUrl: String, newUrl: String)? private(set) var redirect: (originalUrl: String, newUrl: String)?
@@ -120,6 +124,7 @@ final class PageGenerationResults: ObservableObject {
invalidBlocks = [] invalidBlocks = []
warnings = [] warnings = []
unsavedOutputFiles = [:] unsavedOutputFiles = [:]
requiredOutputFiles = [:]
pageIsEmpty = false pageIsEmpty = false
redirect = nil redirect = nil
} }
@@ -151,6 +156,7 @@ final class PageGenerationResults: ObservableObject {
self.invalidBlocks = [] self.invalidBlocks = []
self.warnings = [] self.warnings = []
self.unsavedOutputFiles = [:] self.unsavedOutputFiles = [:]
self.requiredOutputFiles = [:]
self.pageIsEmpty = false self.pageIsEmpty = false
self.redirect = nil self.redirect = nil
} }
@@ -258,6 +264,11 @@ final class PageGenerationResults: ObservableObject {
onMain { self.requiredIcons.formUnion(icons) } 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) { func linked(to page: Page) {
onMain { self.linkedPages.insert(page) } onMain { self.linkedPages.insert(page) }
} }
@@ -284,7 +295,7 @@ final class PageGenerationResults: ObservableObject {
func markPageAsEmpty() { func markPageAsEmpty() {
guard case .page(let page) = itemId.itemType else { return } guard case .page(let page) = itemId.itemType else { return }
onMain { self.pageIsEmpty = true } 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) { 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 @StateObject
private var notifications: NotificationSender = .init() private var notifications: NotificationSender = .init()
@State
private var language: ContentLanguage = .english
@StateObject @StateObject
private var selection: SelectedContent = .init() private var selection: SelectedContent = .init()
@@ -131,7 +128,7 @@ struct MainView: App {
} }
.toolbar { .toolbar {
ToolbarItem { ToolbarItem {
Picker("", selection: $language) { Picker("", selection: $selection.language) {
Text("English") Text("English")
.tag(ContentLanguage.english) .tag(ContentLanguage.english)
Text("German") Text("German")
@@ -180,13 +177,13 @@ struct MainView: App {
} }
} }
.navigationTitle("") .navigationTitle("")
.environment(\.language, language) .environment(\.language, selection.language)
.environmentObject(content) .environmentObject(content)
.environmentObject(selection) .environmentObject(selection)
.onAppear(perform: loadContent) .onAppear(perform: loadContent)
.sheet(isPresented: $showAddSheet) { .sheet(isPresented: $showAddSheet) {
addItemSheet addItemSheet
.environment(\.language, language) .environment(\.language, selection.language)
.environmentObject(content) .environmentObject(content)
.environmentObject(selection) .environmentObject(selection)
} }
@@ -195,13 +192,14 @@ struct MainView: App {
.environmentObject(content) .environmentObject(content)
} }
.sheet(isPresented: $showSettingsSheet) { .sheet(isPresented: $showSettingsSheet) {
SettingsSheet(language: $language) SettingsSheet(language: $selection.language)
.environmentObject(content) .environmentObject(content)
.presentedWindowStyle(.titleBar) .presentedWindowStyle(.titleBar)
} }
.sheet(isPresented: $showGenerationSheet) { .sheet(isPresented: $showGenerationSheet) {
GenerationContentView() GenerationContentView()
.environmentObject(content) .environmentObject(content)
.environmentObject(selection)
} }
.sheet(isPresented: $showPreviewSheet) { .sheet(isPresented: $showPreviewSheet) {
WebsitePreviewSheet() WebsitePreviewSheet()

View File

@@ -2,6 +2,9 @@ import Foundation
final class SelectedContent: ObservableObject { final class SelectedContent: ObservableObject {
@Published
var language: ContentLanguage = .english
@Published @Published
var tab: MainViewTab = .posts var tab: MainViewTab = .posts
@@ -16,4 +19,28 @@ final class SelectedContent: ObservableObject {
@Published @Published
var file: FileResource? 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 { var body: some View {
if let item = selected { if let item = selected {
Contained(item: item) Contained(item: item)
.id(item.id)
} else { } else {
HStack { HStack {
Spacer() Spacer()

View File

@@ -20,6 +20,6 @@ extension FileResource {
} }
static var mock: 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, lastModified: nil,
originalUrl: "projects/electronics/my-first-project/en.html"), originalUrl: "projects/electronics/my-first-project/en.html"),
tags: [ tags: [
content.tags.first(where: { $0.id == "electronics" })! content.tags.first(where: { $0.identifier == "electronics" })!
]) ])
] ]
} }
static var empty: Page { 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, startDate: .now,
endDate: nil, endDate: nil,
tags: [ tags: [
content.tags.first(where: { $0.id == "nature" })!, content.tags.first(where: { $0.identifier == "nature" })!,
content.tags.first(where: { $0.id == "sports" })!, content.tags.first(where: { $0.identifier == "sports" })!,
content.tags.first(where: { $0.id == "hiking" })! content.tags.first(where: { $0.identifier == "hiking" })!
], ],
german: .init( german: .init(
content: content, content: content,
@@ -47,44 +47,44 @@ extension Post {
createdDate: .now, createdDate: .now,
startDate: .now.addingTimeInterval(-86400), endDate: .now, startDate: .now.addingTimeInterval(-86400), endDate: .now,
tags: [ tags: [
content.tags.first(where: { $0.id == "nature" })!, content.tags.first(where: { $0.identifier == "nature" })!,
content.tags.first(where: { $0.id == "sports" })!, content.tags.first(where: { $0.identifier == "sports" })!,
content.tags.first(where: { $0.id == "hiking" })!, content.tags.first(where: { $0.identifier == "hiking" })!,
content.tags.first(where: { $0.id == "mountains" })! content.tags.first(where: { $0.identifier == "mountains" })!
], ],
german: LocalizedPost( german: LocalizedPost(
content: content, content: content,
title: "Eine lange Wanderung", 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.", 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: [ images: [
content.files.first(where: { $0.id == "image1" })!, content.files.first(where: { $0.identifier == "image1" })!,
content.files.first(where: { $0.id == "image2" })!, content.files.first(where: { $0.identifier == "image2" })!,
content.files.first(where: { $0.id == "image3" })!, content.files.first(where: { $0.identifier == "image3" })!,
content.files.first(where: { $0.id == "image4" })! content.files.first(where: { $0.identifier == "image4" })!
]), ]),
english: LocalizedPost( english: LocalizedPost(
content: content, content: content,
title: "A longer hike", title: "A longer hike",
text: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.", text: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.",
images: [ images: [
content.files.first(where: { $0.id == "image1" })!, content.files.first(where: { $0.identifier == "image1" })!,
content.files.first(where: { $0.id == "image2" })!, content.files.first(where: { $0.identifier == "image2" })!,
content.files.first(where: { $0.id == "image3" })!, content.files.first(where: { $0.identifier == "image3" })!,
content.files.first(where: { $0.id == "image4" })! content.files.first(where: { $0.identifier == "image4" })!
])) ]))
] ]
} }
static var empty: Post { static var empty: Post {
Content.mock.posts.first(where: { $0.id == "empty" })! Content.mock.posts.first(where: { $0.identifier == "empty" })!
} }
static var hike: Post { static var hike: Post {
Content.mock.posts.first(where: { $0.id == "hike" })! Content.mock.posts.first(where: { $0.identifier == "hike" })!
} }
static var hike2: Post { 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", urlComponent: "elektronik",
name: "Elektronik", name: "Elektronik",
linkPreview: .init(description: "Eine Beschreibung des Tags", 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" originalUrl: "projects/electronics"
), ),
english: .init( english: .init(
@@ -22,7 +22,7 @@ extension Tag {
name: "Electronics", name: "Electronics",
linkPreview: .init( linkPreview: .init(
description: "Some description of the tag", 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") originalUrl: "projects/electronics")
), ),
Tag( Tag(
@@ -53,23 +53,23 @@ extension Tag {
} }
static var electronics: 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 { static var nature: Tag {
Content.mock.tags.first(where: { $0.id == "nature" })! Content.mock.tags.first(where: { $0.identifier == "nature" })!
} }
static var sports: Tag { static var sports: Tag {
Content.mock.tags.first(where: { $0.id == "sports" })! Content.mock.tags.first(where: { $0.identifier == "sports" })!
} }
static var hiking: Tag { static var hiking: Tag {
Content.mock.tags.first(where: { $0.id == "hiking" })! Content.mock.tags.first(where: { $0.identifier == "hiking" })!
} }
static var mountains: Tag { 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 continue
} }
let path = file.absoluteUrl 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) 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 // MARK: Find items by id
func page(_ pageId: String) -> Page? { func page(_ pageId: String) -> Page? {
pages.first { $0.id == pageId } pages.first { $0.identifier == pageId }
} }
func image(_ imageId: String) -> FileResource? { 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? { 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? { func file(_ fileId: String) -> FileResource? {
files.first { $0.id == fileId } files.first { $0.identifier == fileId }
} }
func tag(_ tagId: String) -> Tag? { func tag(_ tagId: String) -> Tag? {
tags.first { $0.id == tagId } tags.first { $0.identifier == tagId }
} }
// MARK: Generation input // MARK: Generation input
@@ -322,12 +357,12 @@ extension Content {
let pageUrl = settings.general.url + relativePageUrl let pageUrl = settings.general.url + relativePageUrl
guard let content = pageGenerator.generate(page: page, language: language, results: results, pageUrl: pageUrl) else { 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 return
} }
guard storage.write(content, to: filePath) else { guard storage.write(content, to: filePath) else {
print("Failed to save page \(page.id)") print("Failed to save page \(page.identifier)")
return return
} }
@@ -387,7 +422,9 @@ extension Content {
private func updateUnusedFiles() { private func updateUnusedFiles() {
let existing = storage.getAllOutputFiles() let existing = storage.getAllOutputFiles()
DispatchQueue.main.async { DispatchQueue.main.async {
self.results.determineMissingRequiredFiles(existingFiles: existing)
self.results.determineFiles(unusedIn: existing) self.results.determineFiles(unusedIn: existing)
self.results.objectWillChange.send()
} }
} }
} }

View File

@@ -83,16 +83,16 @@ extension Content {
func removeUnlinkedFiles() -> Bool { func removeUnlinkedFiles() -> Bool {
var success = true var success = true
if !storage.deletePostFiles(notIn: posts.map { $0.id }) { if !storage.deletePostFiles(notIn: posts.map { $0.identifier }) {
success = false success = false
} }
if !storage.deletePageFiles(notIn: pages.map { $0.id }) { if !storage.deletePageFiles(notIn: pages.map { $0.identifier }) {
success = false success = false
} }
if !storage.deleteTagFiles(notIn: tags.map { $0.id }) { if !storage.deleteTagFiles(notIn: tags.map { $0.identifier }) {
success = false success = false
} }
if !storage.deleteFileResources(notIn: files.map { $0.id }) { if !storage.deleteFileResources(notIn: files.map { $0.identifier }) {
success = false success = false
} }
return success return success

View File

@@ -7,19 +7,19 @@ extension Content {
private static let disallowedCharactersInFileIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-.")).inverted private static let disallowedCharactersInFileIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-.")).inverted
func isNewIdForTag(_ id: String) -> Bool { 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 { func isNewIdForPage(_ id: String) -> Bool {
!pages.contains { $0.id == id } !pages.contains { $0.identifier == id }
} }
func isNewIdForPost(_ id: String) -> Bool { func isNewIdForPost(_ id: String) -> Bool {
!posts.contains { $0.id == id } !posts.contains { $0.identifier == id }
} }
func isNewIdForFile(_ id: String) -> Bool { func isNewIdForFile(_ id: String) -> Bool {
!files.contains { $0.id == id } !files.contains { $0.identifier == id }
} }
func isValidIdForTagOrPageOrPost(_ id: String) -> Bool { func isValidIdForTagOrPageOrPost(_ id: String) -> Bool {

View File

@@ -2,7 +2,7 @@ import Foundation
import SwiftUI import SwiftUI
import Combine import Combine
final class Content: ObservableObject { final class Content: ChangeObservableItem {
@ObservedObject @ObservedObject
var storage: Storage var storage: Storage
@@ -47,6 +47,11 @@ final class Content: ObservableObject {
var errorCallback: ((StorageError) -> Void)? var errorCallback: ((StorageError) -> Void)?
var cancellables: Set<AnyCancellable> = []
/// A cache of file sizes
private var fileSizes: [String: Int] = [:]
init() { init() {
let settings = Settings.default let settings = Settings.default
self.settings = settings self.settings = settings
@@ -110,6 +115,8 @@ final class Content: ObservableObject {
loadFromDisk(callback: callback) loadFromDisk(callback: callback)
} }
// MARK: Removing items
func remove(_ file: FileResource) { func remove(_ file: FileResource) {
files.remove(file) files.remove(file)
for post in posts { for post in posts {
@@ -126,6 +133,33 @@ final class Content: ObservableObject {
settings.remove(file) 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? { func file(withOutputPath: String) -> FileResource? {
files.first { $0.absoluteUrl == withOutputPath } files.first { $0.absoluteUrl == withOutputPath }
} }
@@ -168,9 +202,9 @@ final class Content: ObservableObject {
for file in self.files { for file in self.files {
guard file.type.isVideo else { continue } guard file.type.isVideo else { continue }
guard !file.isExternallyStored else { continue } guard !file.isExternallyStored else { continue }
guard !storage.hasVideoThumbnail(for: file.id) else { continue } guard !storage.hasVideoThumbnail(for: file.identifier) else { continue }
if await imageGenerator.createVideoThumbnail(for: file.id) { if await imageGenerator.createVideoThumbnail(for: file.identifier) {
print("Generated thumbnail for \(file.id)") print("Generated thumbnail for \(file.identifier)")
file.didChange() file.didChange()
} }
} }
@@ -196,4 +230,36 @@ final class Content: ObservableObject {
func setLastSaveTimestamp() { func setLastSaveTimestamp() {
self.lastSave = .now 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

@@ -33,8 +33,29 @@ extension ContentLanguage: Comparable {
} }
} }
extension ContentLanguage: CustomStringConvertible {
var description: String {
rawValue
}
}
extension ContentLanguage { 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 { var next: ContentLanguage {
switch self { switch self {
case .english: return .german case .english: return .german

View File

@@ -2,7 +2,7 @@ import Foundation
protocol DateItem { protocol DateItem {
var id: String { get } var identifier: String { get }
var startDate: Date { get } var startDate: Date { get }
@@ -20,7 +20,7 @@ extension Sequence where Element: DateItem {
func sortedByStartDateAndId() -> [Element] { func sortedByStartDateAndId() -> [Element] {
sorted { (lhs, rhs) -> Bool in sorted { (lhs, rhs) -> Bool in
if lhs.startDate == rhs.startDate { if lhs.startDate == rhs.startDate {
return lhs.id < rhs.id return lhs.identifier < rhs.identifier
} }
return lhs.startDate > rhs.startDate return lhs.startDate > rhs.startDate
} }

View File

@@ -48,12 +48,22 @@ final class FileResource: Item, LocalizedItem {
var isAsset: Bool var isAsset: Bool
/// The dimensions of the image /// The dimensions of the image
@Published var imageDimensions: CGSize? {
var imageDimensions: CGSize? = nil get { content.dimensions(of: identifier) }
set {
content.cache(dimensions: newValue, of: identifier)
didChange(save: false)
}
}
/// The size of the file in bytes /// The size of the file in bytes
@Published var fileSize: Int? {
var fileSize: Int? = nil get { content.size(of: identifier) }
set {
content.cache(size: newValue, of: identifier)
didChange(save: false)
}
}
var savedData: Data? var savedData: Data?
@@ -104,11 +114,20 @@ final class FileResource: Item, LocalizedItem {
// MARK: Text // MARK: Text
func textContent() -> String { func textContent() -> String {
content.storage.fileContent(for: id) ?? "" content.storage.fileContent(for: identifier) ?? ""
} }
func save(textContent: String) -> Bool { 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 return false
} }
modifiedDate = .now modifiedDate = .now
@@ -116,7 +135,7 @@ final class FileResource: Item, LocalizedItem {
} }
func dataContent() -> Foundation.Data? { func dataContent() -> Foundation.Data? {
content.storage.fileData(for: id) content.storage.fileData(for: identifier)
} }
// MARK: Images // MARK: Images
@@ -155,7 +174,7 @@ final class FileResource: Item, LocalizedItem {
} }
update(fileSize: displayImageData.count) update(fileSize: displayImageData.count)
guard let loadedImage = NSImage(data: displayImageData) else { guard let loadedImage = NSImage(data: displayImageData) else {
print("Failed to create image \(id)") print("Failed to create image \(identifier)")
return nil return nil
} }
update(imageDimensions: loadedImage.size) update(imageDimensions: loadedImage.size)
@@ -181,14 +200,14 @@ final class FileResource: Item, LocalizedItem {
private var displayImageData: Foundation.Data? { private var displayImageData: Foundation.Data? {
if type.isImage { if type.isImage {
guard let data = content.storage.fileData(for: id) else { guard let data = content.storage.fileData(for: identifier) else {
print("Failed to load data for image \(id)") print("Failed to load data for image \(identifier)")
return nil return nil
} }
return data return data
} }
if type.isVideo { if type.isVideo {
return content.storage.getVideoThumbnail(for: id) return content.storage.getVideoThumbnail(for: identifier)
} }
return nil return nil
} }
@@ -224,7 +243,7 @@ final class FileResource: Item, LocalizedItem {
func determineFileSize() { func determineFileSize() {
DispatchQueue.global(qos: .userInitiated).async { 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) 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) /// The path to the output folder where image versions are stored (no leading slash)
var outputImageFolder: String { var outputImageFolder: String {
"\(content.settings.paths.imagesOutputFolderPath)/\(id.fileNameWithoutExtension)" "\(content.settings.paths.imagesOutputFolderPath)/\(identifier.fileNameWithoutExtension)"
} }
func outputPath(width: Int, height: Int, type: FileType?) -> String { func outputPath(width: Int, height: Int, type: FileType?) -> String {
@@ -250,10 +269,11 @@ final class FileResource: Item, LocalizedItem {
return prefix + "." + ext 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) let description = self.localized(in: language)
return .init( return .init(
image: self, image: self,
type: type,
maxWidth: width, maxWidth: width,
maxHeight: height, maxHeight: height,
description: description, description: description,
@@ -279,18 +299,106 @@ final class FileResource: Item, LocalizedItem {
return content.settings.general.url + version.outputPath 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 // MARK: Video thumbnail
func createVideoThumbnail() { func createVideoThumbnail() {
guard type.isVideo else { return } guard type.isVideo else { return }
guard !content.storage.hasVideoThumbnail(for: id) else { return } guard !content.storage.hasVideoThumbnail(for: identifier) else { return }
Task { Task {
if await content.imageGenerator.createVideoThumbnail(for: id) { if await content.imageGenerator.createVideoThumbnail(for: identifier) {
didChange() 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 // MARK: Paths
func removeFileFromOutputFolder() { func removeFileFromOutputFolder() {
@@ -312,7 +420,7 @@ final class FileResource: Item, LocalizedItem {
return "/" + customOutputPath return "/" + customOutputPath
} }
} }
let path = pathPrefix + "/" + id let path = pathPrefix + "/" + identifier
return makeCleanAbsolutePath(path) return makeCleanAbsolutePath(path)
} }
@@ -343,22 +451,37 @@ final class FileResource: Item, LocalizedItem {
@discardableResult @discardableResult
func update(id newId: String) -> Bool { func update(id newId: String) -> Bool {
guard !isExternallyStored else { guard !isExternallyStored else {
id = newId identifier = newId
return true return true
} }
guard content.storage.move(file: id, to: newId) else { guard content.storage.move(file: identifier, to: newId) else {
print("Failed to move file \(id) to \(newId)") print("Failed to move file \(identifier) to \(newId)")
return false return false
} }
id = newId identifier = newId
return true 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 { extension FileResource: CustomStringConvertible {
var description: String { var description: String {
id identifier
} }
} }
@@ -408,6 +531,6 @@ extension FileResource: StorageItem {
} }
func saveToDisk(_ data: Data) -> Bool { 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 video
case resource case resource
case audio case audio
case route
var text: String { var text: String {
switch self { switch self {
@@ -19,6 +20,7 @@ enum FileTypeCategory: String, CaseIterable {
case .video: return "Videos" case .video: return "Videos"
case .resource: return "Other" case .resource: return "Other"
case .audio: return "Audio" case .audio: return "Audio"
case .route: return "Route"
} }
} }
@@ -31,6 +33,7 @@ enum FileTypeCategory: String, CaseIterable {
case .video: .video case .video: .video
case .resource: .zipperPage case .resource: .zipperPage
case .audio: .speakerWave2CircleFill case .audio: .speakerWave2CircleFill
case .route: .map
} }
} }
} }
@@ -138,6 +141,10 @@ enum FileType: String {
case psd case psd
// MARK: Route
case route
// MARK: Unknown // MARK: Unknown
case unknown case unknown
@@ -174,6 +181,8 @@ enum FileType: String {
return .model return .model
case .zip, .cddx, .pdf, .key, .psd, .ttf: case .zip, .cddx, .pdf, .key, .psd, .ttf:
return .resource return .resource
case .route:
return .route
case .noExtension, .unknown: case .noExtension, .unknown:
return .resource return .resource
} }
@@ -225,15 +234,11 @@ enum FileType: String {
default: return false default: return false
} }
} }
}
var htmlType: String? { extension FileType: CustomStringConvertible {
switch self {
case .mp4, .m4v: var description: String {
return "video/mp4" rawValue
case .webm:
return "video/webm"
default:
return nil
}
} }
} }

View File

@@ -8,7 +8,9 @@ class ChangeObservingItem: ObservableContentItem {
/// A dummy property to force views to update when properties change /// A dummy property to force views to update when properties change
@Published @Published
private var changeToggle = false private var changeToggle = false
private var shouldSave = true
var cancellables = Set<AnyCancellable>() var cancellables = Set<AnyCancellable>()
init(content: Content) { init(content: Content) {
@@ -19,9 +21,18 @@ class ChangeObservingItem: ObservableContentItem {
// MARK: Change observation // MARK: Change observation
func didChange() { func didChange(save: Bool = true) {
DispatchQueue.main.async { DispatchQueue.main.async {
self.shouldSave = save
self.changeToggle.toggle() self.changeToggle.toggle()
self.shouldSave = true
} }
} }
func needsSaving() {
guard shouldSave else {
return
}
content.needsSave()
}
} }

View File

@@ -7,11 +7,17 @@ class Item: ChangeObservingItem, Identifiable {
@Published @Published
private var changeToggle = false private var changeToggle = false
/// A session-id for the item for identification
let id = UUID()
/// The unique, persistent identifier of the item
///
/// This identifier is not used for `Identifiable`, since it may be changed through the UI.
@Published @Published
var id: String var identifier: String
init(content: Content, id: String) { init(content: Content, id: String) {
self.id = id self.identifier = id
super.init(content: content) super.init(content: content)
observeChanges() observeChanges()
@@ -44,14 +50,14 @@ class Item: ChangeObservingItem, Identifiable {
} }
var itemId: ItemId { var itemId: ItemId {
.init(type: itemType, id: id) .init(type: itemType, id: identifier)
} }
} }
extension Item: Equatable { extension Item: Equatable {
static func == (lhs: Item, rhs: Item) -> Bool { static func == (lhs: Item, rhs: Item) -> Bool {
lhs.id == rhs.id && lhs.itemType == rhs.itemType lhs.id == rhs.id
} }
} }
@@ -59,13 +65,12 @@ extension Item: Hashable {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
hasher.combine(itemType)
} }
} }
extension Item: Comparable { extension Item: Comparable {
static func < (lhs: Item, rhs: Item) -> Bool { 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: case .feed:
return "1-feed" return "1-feed"
case .post(let post): case .post(let post):
return "2-post-\(post.id)" return "2-post-\(post.identifier)"
case .page(let page): case .page(let page):
return "3-page-\(page.id)" return "3-page-\(page.identifier)"
case .tagPage(let tag): case .tagPage(let tag):
return "5-tag-\(tag.id)" return "5-tag-\(tag.identifier)"
case .tagOverview: case .tagOverview:
return "4-tag-overview" return "4-tag-overview"
} }
@@ -76,11 +76,11 @@ extension ItemReference: CustomStringConvertible {
case .feed: case .feed:
return "Feed" return "Feed"
case .post(let post): case .post(let post):
return "Post \(post.id)" return "Post \(post.identifier)"
case .page(let page): case .page(let page):
return "Page \(page.id)" return "Page \(page.identifier)"
case .tagPage(let tag): case .tagPage(let tag):
return "Tag \(tag.id)" return "Tag \(tag.identifier)"
case .tagOverview: case .tagOverview:
return "Tag Overview" return "Tag Overview"
} }

View File

@@ -31,3 +31,10 @@ extension LocalizedItemId: Comparable {
return lhs.language < rhs.language 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 // MARK: Storage
var data: Data { var data: Data {
.init(title: title, description: description, image: image?.id) .init(title: title, description: description, image: image?.identifier)
} }
init(context: LoadingContext, data: Data) { init(context: LoadingContext, data: Data) {

View File

@@ -27,7 +27,7 @@ final class LoadingContext {
posts: posts.values.sortedByStartDateAndId(), posts: posts.values.sortedByStartDateAndId(),
pages: pages.values.sortedByStartDateAndId(), pages: pages.values.sortedByStartDateAndId(),
tags: tags.values.sorted(), tags: tags.values.sorted(),
files: files.values.sorted { $0.id }, files: files.values.sorted { $0.identifier },
tagOverview: tagOverview, tagOverview: tagOverview,
errors: errors.sorted().map { StorageError(message: $0) }) errors: errors.sorted().map { StorageError(message: $0) })
} }

View File

@@ -61,6 +61,14 @@ final class LocalizedPage: ChangeObservingItem {
content.isValidIdForTagOrPageOrPost(urlComponent) && content.isValidIdForTagOrPageOrPost(urlComponent) &&
!content.containsPage(withUrlComponent: urlComponent) !content.containsPage(withUrlComponent: urlComponent)
} }
func update(hasContent: Bool) -> Bool {
if self.hasContent != hasContent {
self.hasContent = hasContent
return true
}
return false
}
} }

View File

@@ -96,7 +96,7 @@ extension LocalizedPost {
} }
var data: Data { var data: Data {
.init(images: images.map { $0.id }, .init(images: images.map { $0.identifier },
labels: labels.map { $0.data }.nonEmpty, labels: labels.map { $0.data }.nonEmpty,
title: title, title: title,
text: text, text: text,

View File

@@ -75,11 +75,11 @@ final class Page: Item, DateItem, LocalizedItem {
@discardableResult @discardableResult
func update(id newId: String) -> Bool { func update(id newId: String) -> Bool {
guard content.storage.move(page: id, to: newId) else { guard content.storage.move(page: identifier, to: newId) else {
print("Failed to move files of page \(id)") print("Failed to move files of page \(identifier)")
return false return false
} }
id = newId identifier = newId
return true return true
} }
@@ -146,22 +146,26 @@ final class Page: Item, DateItem, LocalizedItem {
} }
func pageContent(in language: ContentLanguage) -> String? { 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 { 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 return false
} }
localized(in: language).hasContent = false if localized(in: language).update(hasContent: false) {
self.didChange()
}
return true return true
} }
func save(pageContent: String, in language: ContentLanguage) -> Bool { 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 return false
} }
localized(in: language).hasContent = true if localized(in: language).update(hasContent: true) {
self.didChange()
}
return true return true
} }
@@ -169,8 +173,15 @@ final class Page: Item, DateItem, LocalizedItem {
Update the `hasContent` property of all localized pages. Update the `hasContent` property of all localized pages.
*/ */
func updateContentExistence() { func updateContentExistence() {
var didUpdate = false
for language in ContentLanguage.allCases { 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( .init(
isDraft: isDraft, isDraft: isDraft,
externalLink: externalLink, externalLink: externalLink,
tags: tags.map { $0.id }, tags: tags.map { $0.identifier },
hideDate: hideDate ? true : nil, hideDate: hideDate ? true : nil,
createdDate: createdDate, createdDate: createdDate,
startDate: startDate, startDate: startDate,
@@ -233,6 +244,6 @@ extension Page: StorageItem {
} }
func saveToDisk(_ data: Data) -> Bool { 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 @Published
var linkedPage: Page? var linkedPage: Page?
/// The workouts associated with the post
@Published
var associatedWorkouts: [FileResource]
init(content: Content, init(content: Content,
id: String, id: String,
isDraft: Bool, isDraft: Bool,
@@ -47,7 +51,8 @@ final class Post: Item, DateItem, LocalizedItem {
tags: [Tag], tags: [Tag],
german: LocalizedPost, german: LocalizedPost,
english: LocalizedPost, english: LocalizedPost,
linkedPage: Page? = nil) { linkedPage: Page? = nil,
associatedWorkouts: [FileResource] = []) {
self.isDraft = isDraft self.isDraft = isDraft
self.createdDate = createdDate self.createdDate = createdDate
self.startDate = startDate self.startDate = startDate
@@ -57,6 +62,7 @@ final class Post: Item, DateItem, LocalizedItem {
self.german = german self.german = german
self.english = english self.english = english
self.linkedPage = linkedPage self.linkedPage = linkedPage
self.associatedWorkouts = associatedWorkouts
super.init(content: content, id: id) super.init(content: content, id: id)
} }
@@ -118,11 +124,11 @@ final class Post: Item, DateItem, LocalizedItem {
A title for the UI, not the generation. A title for the UI, not the generation.
*/ */
override func title(in language: ContentLanguage) -> String { override func title(in language: ContentLanguage) -> String {
localized(in: language).title ?? id localized(in: language).title ?? identifier
} }
func contains(_ string: String) -> Bool { func contains(_ string: String) -> Bool {
id.contains(string) || identifier.contains(string) ||
german.contains(string) || german.contains(string) ||
english.contains(string) english.contains(string)
} }
@@ -135,11 +141,11 @@ final class Post: Item, DateItem, LocalizedItem {
@discardableResult @discardableResult
func update(id newId: String) -> Bool { func update(id newId: String) -> Bool {
guard content.storage.move(post: id, to: newId) else { guard content.storage.move(post: identifier, to: newId) else {
print("Failed to move file of post \(id)") print("Failed to move file of post \(identifier)")
return false return false
} }
id = newId identifier = newId
return true return true
} }
@@ -149,10 +155,10 @@ final class Post: Item, DateItem, LocalizedItem {
} }
func makePage() -> Page { func makePage() -> Page {
var id = self.id var id = self.identifier
var number = 2 var number = 2
while !content.isNewIdForPage(id) { while !content.isNewIdForPage(id) {
id += "\(self.id)-\(number)" id += "\(self.identifier)-\(number)"
number += 1 number += 1
} }
// Move tags to page // Move tags to page
@@ -174,6 +180,16 @@ final class Post: Item, DateItem, LocalizedItem {
english: english, english: english,
tags: tags) 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 { extension Post: StorageItem {
@@ -189,7 +205,8 @@ extension Post: StorageItem {
tags: data.tags.compactMap(context.tag), tags: data.tags.compactMap(context.tag),
german: .init(context: context, data: data.german), german: .init(context: context, data: data.german),
english: .init(context: context, data: data.english), 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 savedData = data
} }
@@ -202,6 +219,7 @@ extension Post: StorageItem {
let german: LocalizedPost.Data let german: LocalizedPost.Data
let english: LocalizedPost.Data let english: LocalizedPost.Data
let linkedPageId: String? let linkedPageId: String?
let associatedWorkoutIds: [String]?
} }
var data: Data { var data: Data {
@@ -210,13 +228,14 @@ extension Post: StorageItem {
createdDate: createdDate, createdDate: createdDate,
startDate: startDate, startDate: startDate,
endDate: endDate, endDate: endDate,
tags: tags.map { $0.id }, tags: tags.map { $0.identifier },
german: german.data, german: german.data,
english: english.data, english: english.data,
linkedPageId: linkedPage?.id) linkedPageId: linkedPage?.identifier,
associatedWorkoutIds: associatedWorkouts.map { $0.identifier}.nonEmpty )
} }
func saveToDisk(_ data: Data) -> Bool { 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 { var data: Data {
.init(playlistCoverImageSize: playlistCoverImageSize, .init(playlistCoverImageSize: playlistCoverImageSize,
smallCoverImageSize: smallCoverImageSize, smallCoverImageSize: smallCoverImageSize,
audioPlayerJsFile: audioPlayerJsFile?.id, audioPlayerJsFile: audioPlayerJsFile?.identifier,
audioPlayerCssFile: audioPlayerCssFile?.id, audioPlayerCssFile: audioPlayerCssFile?.identifier,
german: german.data, german: german.data,
english: english.data) english: english.data)
} }

View File

@@ -65,7 +65,7 @@ extension GeneralSettings {
remotePortForUpload: remotePortForUpload, remotePortForUpload: remotePortForUpload,
remotePathForUpload: remotePathForUpload, remotePathForUpload: remotePathForUpload,
urlForPushNotification: urlForPushNotification, urlForPushNotification: urlForPushNotification,
requiredFiles: requiredFiles.nonEmpty?.map { $0.id }.sorted()) requiredFiles: requiredFiles.nonEmpty?.map { $0.identifier }.sorted())
} }
struct Data: Codable, Equatable { struct Data: Codable, Equatable {

View File

@@ -113,13 +113,13 @@ extension PageSettings {
.init(contentWidth: contentWidth, .init(contentWidth: contentWidth,
largeImageWidth: largeImageWidth, largeImageWidth: largeImageWidth,
pageLinkImageSize: pageLinkImageSize, pageLinkImageSize: pageLinkImageSize,
defaultCssFile: defaultCssFile?.id, defaultCssFile: defaultCssFile?.identifier,
codeHighlightingJsFile: codeHighlightingJsFile?.id, codeHighlightingJsFile: codeHighlightingJsFile?.identifier,
modelViewerJsFile: modelViewerJsFile?.id, modelViewerJsFile: modelViewerJsFile?.identifier,
imageCompareJsFile: imageCompareJsFile?.id, imageCompareJsFile: imageCompareJsFile?.identifier,
imageCompareCssFile: imageCompareCssFile?.id, imageCompareCssFile: imageCompareCssFile?.identifier,
manifestFile: manifestFile?.id, manifestFile: manifestFile?.identifier,
routeJsFile: routeJsFile?.id, routeJsFile: routeJsFile?.identifier,
german: german.data, german: german.data,
english: english.data) english: english.data)
} }

View File

@@ -72,9 +72,9 @@ extension PostSettings {
var data: PostSettings.Data { var data: PostSettings.Data {
.init(postsPerPage: postsPerPage, .init(postsPerPage: postsPerPage,
contentWidth: contentWidth, contentWidth: contentWidth,
swiperCssFile: swiperCssFile?.id, swiperCssFile: swiperCssFile?.identifier,
swiperJsFile: swiperJsFile?.id, swiperJsFile: swiperJsFile?.identifier,
defaultCssFile: defaultCssFile?.id, defaultCssFile: defaultCssFile?.identifier,
german: german.data, german: german.data,
english: english.data) english: english.data)
} }

View File

@@ -22,6 +22,9 @@ final class Settings: ChangeObservableItem {
@Published @Published
var audioPlayer: AudioPlayerSettings var audioPlayer: AudioPlayerSettings
@Published
var tools: ToolSettings
weak var content: Content? weak var content: Content?
var cancellables: Set<AnyCancellable> = [] var cancellables: Set<AnyCancellable> = []
@@ -31,13 +34,16 @@ final class Settings: ChangeObservableItem {
navigation: NavigationSettings, navigation: NavigationSettings,
posts: PostSettings, posts: PostSettings,
pages: PageSettings, pages: PageSettings,
audioPlayer: AudioPlayerSettings) { audioPlayer: AudioPlayerSettings,
tools: ToolSettings) {
self.general = general self.general = general
self.paths = paths self.paths = paths
self.navigation = navigation self.navigation = navigation
self.posts = posts self.posts = posts
self.pages = pages self.pages = pages
self.audioPlayer = audioPlayer self.audioPlayer = audioPlayer
self.tools = tools
observeChildChanges()
} }
func remove(_ file: FileResource) { func remove(_ file: FileResource) {
@@ -49,6 +55,16 @@ final class Settings: ChangeObservableItem {
func needsSaving() { func needsSaving() {
content?.needsSave() content?.needsSave()
} }
private func observeChildChanges() {
observe(general)
observe(paths)
observe(navigation)
observe(posts)
observe(pages)
observe(audioPlayer)
observe(tools)
}
} }
// MARK: Storage // MARK: Storage
@@ -62,7 +78,8 @@ extension Settings {
navigation: .init(context: context, data: data.navigation), navigation: .init(context: context, data: data.navigation),
posts: .init(context: context, data: data.posts), posts: .init(context: context, data: data.posts),
pages: .init(context: context, data: data.pages), 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 content = context.content
} }
@@ -74,7 +91,8 @@ extension Settings {
posts: posts.data, posts: posts.data,
pages: pages.data, pages: pages.data,
audioPlayer: audioPlayer.data, audioPlayer: audioPlayer.data,
tagOverview: tagOverview?.data) tagOverview: tagOverview?.data,
tools: tools.data)
} }
struct Data: Codable, Equatable { struct Data: Codable, Equatable {
@@ -85,6 +103,7 @@ extension Settings {
let pages: PageSettings.Data let pages: PageSettings.Data
let audioPlayer: AudioPlayerSettings.Data let audioPlayer: AudioPlayerSettings.Data
let tagOverview: Tag.Data? let tagOverview: Tag.Data?
let tools: ToolSettings.Data
} }
func saveToDisk(_ data: Data) -> Bool { func saveToDisk(_ data: Data) -> Bool {
@@ -100,7 +119,8 @@ extension Settings {
navigation: .default, navigation: .default,
posts: .default, posts: .default,
pages: .default, pages: .default,
audioPlayer: .default) audioPlayer: .default,
tools: .default)
} }
extension GeneralSettings { extension GeneralSettings {
@@ -185,3 +205,11 @@ extension PageSettings {
emptyPageText: "This page is empty")) 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 @discardableResult
func update(id newId: String) -> Bool { func update(id newId: String) -> Bool {
guard content.storage.move(tag: id, to: newId) else { guard content.storage.move(tag: identifier, to: newId) else {
print("Failed to move files of tag \(id)") print("Failed to move files of tag \(identifier)")
return false return false
} }
id = newId identifier = newId
return true return true
} }
@@ -106,6 +106,6 @@ extension Tag: StorageItem {
} }
func saveToDisk(_ data: Data) -> Bool { 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 { final class TagOverview: Tag {
override var itemId: ItemId { 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 bellSlash = "bell-slash"
case pencil
case personPlus = "person-plus"
// MARK: Statistics // MARK: Statistics
case statisticsTime = "time" case statisticsTime = "time"
@@ -97,6 +101,8 @@ enum PageIcon: String, CaseIterable {
case .leftRightArrow: Icon.LeftRightArrow.self case .leftRightArrow: Icon.LeftRightArrow.self
case .bell: Icon.Bell.self case .bell: Icon.Bell.self
case .bellSlash: Icon.BellSlash.self case .bellSlash: Icon.BellSlash.self
case .pencil: Icon.Pencil.self
case .personPlus: Icon.PersonPlus.self
} }
} }
@@ -111,6 +117,8 @@ enum PageIcon: String, CaseIterable {
case .video: "Video" case .video: "Video"
case .bell: "Bell" case .bell: "Bell"
case .bellSlash: "Bell With Slash" case .bellSlash: "Bell With Slash"
case .pencil: "Pencil"
case .personPlus: "Person Plus"
case .leftRightArrow: "LeftRightArrow" case .leftRightArrow: "LeftRightArrow"
case .buttonExternalLink: "Button: External Link" case .buttonExternalLink: "Button: External Link"
case .buttonGitLink: "Button: Git Link" case .buttonGitLink: "Button: Git Link"

View File

@@ -1,13 +1,18 @@
struct PostVideo: HtmlProducer { struct PostVideo: HtmlProducer {
let videos: [FileResource] struct Video {
let path: String
let type: String
}
let videos: [Video]
func populate(_ result: inout String) { func populate(_ result: inout String) {
result += "<video autoplay loop muted playsinline>" result += "<video autoplay loop muted playsinline>"
result += "Video not supported." result += "Video not supported."
for video in videos { for video in videos {
result += "<source src='\(video.absoluteUrl)' type='\(video.type.htmlType!)'>" result += "<source src='\(video.path)' type='\(video.type)'>"
} }
result += "</video>" result += "</video>"
} }

View File

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

View File

@@ -7,6 +7,25 @@ protocol ChangeObservableItem: ObservableObject {
func needsSaving() 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 { protocol ObservableContentItem: ChangeObservableItem {
var content: Content { get } var content: Content { get }
@@ -18,14 +37,3 @@ extension ObservableContentItem {
content.needsSave() 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 SFSafeSymbols
import SwiftUICore import SwiftUI
enum SaveState { enum SaveState {
case storageNotInitialized case storageNotInitialized

View File

@@ -183,7 +183,7 @@ struct SecurityBookmark {
with(relativePath: relativeSource) { source in with(relativePath: relativeSource) { source in
if !exists(source) { if !exists(source) {
if !failIfMissing { return true } 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 return false
} }

View File

@@ -147,6 +147,27 @@ final class Storage: ObservableObject {
return result 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 // MARK: Posts
private func postFileName(_ postId: String) -> String { private func postFileName(_ postId: String) -> String {
@@ -186,6 +207,14 @@ final class Storage: ObservableObject {
return contentScope.move(postFilePath(post: postId), to: postFilePath(post: newId)) 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 // MARK: Tags
private func tagFileName(tagId: String) -> String { private func tagFileName(tagId: String) -> String {
@@ -225,6 +254,11 @@ final class Storage: ObservableObject {
return contentScope.move(tagFilePath(tag: tagId), to: tagFilePath(tag: newId)) 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 // MARK: Files
func size(of file: String) -> Int? { func size(of file: String) -> Int? {
@@ -380,6 +414,12 @@ final class Storage: ObservableObject {
return contentScope.readData(at: path) 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 { func save(fileContent: String, for fileId: String) -> Bool {
guard let contentScope else { return false } guard let contentScope else { return false }
let path = filePath(file: fileId) let path = filePath(file: fileId)
@@ -392,6 +432,12 @@ final class Storage: ObservableObject {
return await contentScope.with(relativePath: path, perform: operation) 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 // MARK: Video thumbnails
func hasVideoThumbnail(for videoId: String) -> Bool { func hasVideoThumbnail(for videoId: String) -> Bool {

View File

@@ -78,6 +78,10 @@ struct AddFileView: View {
} }
private func importSelectedFiles() { private func importSelectedFiles() {
guard !filesToAdd.isEmpty else {
dismiss()
return
}
for file in filesToAdd { for file in filesToAdd {
guard file.isSelected else { guard file.isSelected else {
print("Skipping unselected file \(file.uniqueId)") print("Skipping unselected file \(file.uniqueId)")
@@ -104,6 +108,9 @@ struct AddFileView: View {
content.add(resource) content.add(resource)
selectedFile = resource selectedFile = resource
} }
// We need to ensure that the metadata file is written to disk directly
content.saveUnconditionally()
content.generateMissingVideoThumbnails() content.generateMissingVideoThumbnails()
dismiss() dismiss()
} }

View File

@@ -41,7 +41,7 @@ struct FileContentView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
case .text, .code: case .text, .code:
TextFileContentView(file: file) TextFileContentView(file: file)
.id(file.id + file.modifiedDate.description) .id(file.identifier + file.modifiedDate.description)
case .video: case .video:
VStack { VStack {
if let image = file.imageToDisplay { if let image = file.imageToDisplay {
@@ -77,6 +77,9 @@ struct FileContentView: View {
.font(.title) .font(.title)
} }
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
case .route:
RoutePreviewView(file: file)
} }
} }
}.padding() }.padding()

View File

@@ -34,6 +34,9 @@ struct FileDetailView: View {
@EnvironmentObject @EnvironmentObject
private var content: Content private var content: Content
@EnvironmentObject
private var selection: SelectedContent
@Environment(\.language) @Environment(\.language)
private var language private var language
@@ -48,28 +51,33 @@ struct FileDetailView: View {
text: "A file that can be used in a post or page") text: "A file that can be used in a post or page")
GenericPropertyView(title: "Actions") { GenericPropertyView(title: "Actions") {
HStack(spacing: 10) { if file.isExternallyStored {
ButtonIcon(.folder, action: showFileInFinder) ColoredButton(
ButtonIcon(.arrowClockwise, action: markFileAsChanged) icon: .squareAndArrowDown,
if file.isExternallyStored { text: "Convert to internal file",
ButtonIcon(.squareAndArrowDown, action: replaceFile) action: replaceFile)
} else { } else {
ButtonIcon(.arrowLeftArrowRight, action: replaceFile) ColoredButton(icon: .folder, text: "Show in folder", action: showFileInFinder)
ButtonIcon(.squareDashed, action: convertToExternal) ColoredButton(icon: .arrowClockwise, text: "Mark file as changed", action: markFileAsChanged)
} ColoredButton(
ButtonIcon(.trash, action: deleteFile) icon: .arrowLeftArrowRight,
.foregroundStyle(.red) text: "Replace file",
action: replaceFile)
ColoredButton(
icon: .squareDashed,
text: "Convert to external file",
action: convertToExternal)
} }
.buttonStyle(.plain) ColoredButton(delete: deleteFile)
.foregroundStyle(.blue)
} }
IdPropertyView( IdPropertyView(
id: $file.id, id: $file.identifier,
title: "Name", title: "Name",
footer: "The unique name of the file, which is also used to reference it in posts and pages.", footer: "The unique name of the file, which is also used to reference it in posts and pages.",
validation: file.isValid, validation: file.isValid,
update: { file.update(id: $0) }) update: { file.update(id: $0) })
.id(file.id)
switch language { switch language {
case .english: case .english:
@@ -147,7 +155,7 @@ struct FileDetailView: View {
} }
private func showFileInFinder() { private func showFileInFinder() {
content.storage.openFinderWindow(withSelectedFile: file.id) content.storage.openFinderWindow(withSelectedFile: file.identifier)
} }
private func markFileAsChanged() { private func markFileAsChanged() {
@@ -162,17 +170,18 @@ struct FileDetailView: View {
private func replaceFile() { private func replaceFile() {
guard let url = openFilePanel() else { guard let url = openFilePanel() else {
print("File '\(file.id)': No file selected as replacement") print("File '\(file.identifier)': No file selected as replacement")
return return
} }
guard content.storage.importExternalFile(at: url, fileId: file.id) else { guard content.storage.importExternalFile(at: url, fileId: file.identifier) else {
print("File '\(file.id)': Failed to replace file") print("File '\(file.identifier)': Failed to replace file")
return return
} }
markFileAsChanged() markFileAsChanged()
if file.isExternallyStored { if file.isExternallyStored {
DispatchQueue.main.async { DispatchQueue.main.async {
// This will also trigger a save
file.isExternallyStored = false file.isExternallyStored = false
} }
} }
@@ -190,7 +199,7 @@ struct FileDetailView: View {
let response = panel.runModal() let response = panel.runModal()
guard response == .OK else { 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 return nil
} }
@@ -202,22 +211,25 @@ struct FileDetailView: View {
return return
} }
guard content.storage.removeFileContent(file: file.id) else { guard content.storage.removeFileContent(file: file.identifier) else {
print("File '\(file.id)': Failed to delete file to make it external") print("File '\(file.identifier)': Failed to delete file to make it external")
return return
} }
DispatchQueue.main.async { DispatchQueue.main.async {
// This will also trigger a save
file.fileSize = nil file.fileSize = nil
file.isExternallyStored = true file.isExternallyStored = true
} }
} }
private func deleteFile() { private func deleteFile() {
guard content.storage.delete(file: file.id) else { guard content.storage.delete(file: file.identifier) else {
print("File '\(file.id)': Failed to delete file in content folder") print("File '\(file.identifier)': Failed to delete file in content folder")
return return
} }
// This will also trigger a save
content.remove(file) content.remove(file)
selection.remove(file)
} }
} }

View File

@@ -32,7 +32,7 @@ struct FileListView: View {
guard !searchString.isEmpty else { guard !searchString.isEmpty else {
return filesBySelectedType return filesBySelectedType
} }
return filesBySelectedType.filter { $0.id.contains(searchString) } return filesBySelectedType.filter { $0.identifier.contains(searchString) }
} }
var body: some View { var body: some View {
@@ -55,10 +55,10 @@ struct FileListView: View {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(filteredFiles) { file in ForEach(filteredFiles) { file in
SelectableListItem(selected: selectedFile == file) { SelectableListItem(selected: selectedFile == file) {
Text(file.id) Text(file.identifier)
.lineLimit(1) .lineLimit(1)
} }
.id(file.id) .id(file.identifier)
.onTapGesture { .onTapGesture {
selectedFile = file selectedFile = file
} }

View File

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

View File

@@ -43,7 +43,7 @@ struct MultiFileSelectionView: View {
guard !searchString.isEmpty else { guard !searchString.isEmpty else {
return filesBySelectedType return filesBySelectedType
} }
return filesBySelectedType.filter { $0.id.contains(searchString) } return filesBySelectedType.filter { $0.identifier.contains(searchString) }
} }
var body: some View { var body: some View {
@@ -59,7 +59,7 @@ struct MultiFileSelectionView: View {
.foregroundStyle(.red) .foregroundStyle(.red)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { deselect(file: file) } .onTapGesture { deselect(file: file) }
Text(file.id) Text(file.identifier)
Spacer() Spacer()
} }
} }
@@ -99,7 +99,7 @@ struct MultiFileSelectionView: View {
Image(systemSymbol: .plusCircleFill) Image(systemSymbol: .plusCircleFill)
.foregroundStyle(.green) .foregroundStyle(.green)
} }
Text(file.id) Text(file.identifier)
Spacer() Spacer()
} }
.contentShape(Rectangle()) .contentShape(Rectangle())

View File

@@ -49,9 +49,9 @@ struct TextFileContentView: View {
private func reload() { private func reload() {
fileContent = file.textContent() fileContent = file.textContent()
loadedFile = file.id loadedFile = file.identifier
loadedFileDate = file.modifiedDate loadedFileDate = file.modifiedDate
print("Loaded content of file \(file.id)") print("Loaded content of file \(file.identifier)")
} }
private func save() { private func save() {
@@ -59,25 +59,25 @@ struct TextFileContentView: View {
print("[ERROR] Text File View: No file loaded to save") print("[ERROR] Text File View: No file loaded to save")
return return
} }
guard loadedFile == file.id else { guard loadedFile == file.identifier else {
print("[ERROR] Text File View: Not saving since file changed") print("[ERROR] Text File View: Not saving since file changed")
reload() reload()
return return
} }
guard loadedFileDate == file.modifiedDate else { 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() reload()
return return
} }
guard fileContent != "" else { guard fileContent != "" else {
print("Text File View: Not saving empty file \(file.id)") print("Text File View: Not saving empty file \(file.identifier)")
return return
} }
guard file.save(textContent: fileContent) else { 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 return
} }
loadedFileDate = file.modifiedDate 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 @EnvironmentObject
private var content: Content private var content: Content
@EnvironmentObject
private var selection: SelectedContent
@Environment(\.dismiss) @Environment(\.dismiss)
private var 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 { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("Website Generation") Text("Website Generation")
@@ -45,7 +56,7 @@ struct GenerationContentView: View {
GenerationStringIssuesView( GenerationStringIssuesView(
text: "output files", text: "output files",
statusWhenNonEmpty: .nominal, statusWhenNonEmpty: .nominal,
items: $content.results.outputFiles) items: content.results.outputFiles)
GenerationResultsIssueView( GenerationResultsIssueView(
text: "\(content.results.imagesToGenerate.count) images", text: "\(content.results.imagesToGenerate.count) images",
status: .nominal, status: .nominal,
@@ -57,54 +68,94 @@ struct GenerationContentView: View {
GenerationStringIssuesView( GenerationStringIssuesView(
text: "external links", text: "external links",
statusWhenNonEmpty: .nominal, statusWhenNonEmpty: .nominal,
items: $content.results.externalLinks) items: content.results.externalLinks)
GenerationStringIssuesView( GenerationStringIssuesView(
text: "required files", text: "required files",
statusWhenNonEmpty: .nominal, statusWhenNonEmpty: .nominal,
items: $content.results.requiredFiles) { $0.id } items: content.results.requiredFiles) { $0.identifier }
GenerationStringIssuesView( GenerationStringIssuesView(
text: "external files", text: "external files",
statusWhenNonEmpty: .nominal, statusWhenNonEmpty: .nominal,
items: $content.results.externalFiles) { $0.id } items: content.results.externalFiles) { $0.identifier }
GenerationStringIssuesView( GenerationIssuesView(
text: "empty pages", text: "empty pages",
statusWhenNonEmpty: .warning, statusWhenNonEmpty: .warning,
items: $content.results.emptyPages) { "\($0.pageId) (\($0.language))" } items: $content.results.emptyPages) { pageId in
GenerationStringIssuesView( 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", text: "additional output files",
statusWhenNonEmpty: .warning, 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( GenerationStringIssuesView(
text: "inaccessible files", text: "inaccessible files",
items: $content.results.inaccessibleFiles) { $0.id } items: content.results.inaccessibleFiles) { $0.identifier }
GenerationStringIssuesView( GenerationStringIssuesView(
text: "unparsable files", text: "unparsable files",
items: $content.results.unparsableFiles) { $0.id } items: content.results.unparsableFiles) { $0.identifier }
GenerationStringIssuesView( GenerationStringIssuesView(
text: "unsaved output files", text: "unsaved output files",
items: $content.results.unsavedOutputFiles) items: content.results.unsavedOutputFiles)
GenerationStringIssuesView( GenerationStringIssuesView(
text: "failed image generations", text: "failed image generations",
items: $content.results.failedImages) { $0.outputPath } items: content.results.failedImages) { $0.outputPath }
GenerationStringIssuesView( GenerationStringIssuesView(
text: "missing files", text: "missing files",
items: $content.results.missingFiles) items: content.results.missingFiles)
GenerationStringIssuesView( GenerationStringIssuesView(
text: "missing tags", text: "missing tags",
items: $content.results.missingTags) items: content.results.missingTags)
GenerationStringIssuesView( GenerationStringIssuesView(
text: "missing pages", 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( GenerationStringIssuesView(
text: "invalid commands", text: "invalid commands",
items: $content.results.invalidCommands) items: content.results.invalidCommands)
GenerationStringIssuesView( GenerationStringIssuesView(
text: "invalid blocks", text: "invalid blocks",
items: $content.results.invalidBlocks) items: content.results.invalidBlocks)
GenerationStringIssuesView( GenerationStringIssuesView(
text: "warnings", text: "warnings",
statusWhenNonEmpty: .warning, statusWhenNonEmpty: .warning,
items: $content.results.warnings) items: content.results.warnings)
HorizontalCenter { HorizontalCenter {
Button(action: { dismiss() }) { Button(action: { dismiss() }) {
Text("Close") Text("Close")
@@ -112,6 +163,38 @@ struct GenerationContentView: View {
} }
}.padding() }.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 { #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 let statusWhenNonEmpty: IssueStatus
@Binding let items: Set<T>
var items: Set<T>
let map: (T) -> String let map: (T) -> String
@@ -18,10 +17,10 @@ struct GenerationStringIssuesView<T>: View where T: Hashable {
items.isEmpty ? .nominal : statusWhenNonEmpty 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.text = text
self.statusWhenNonEmpty = statusWhenNonEmpty self.statusWhenNonEmpty = statusWhenNonEmpty
self._items = items self.items = items
self.map = map self.map = map
} }
@@ -56,10 +55,10 @@ struct GenerationStringIssuesView<T>: View where T: Hashable {
extension GenerationStringIssuesView where T == String { 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.text = text
self.statusWhenNonEmpty = statusWhenNonEmpty self.statusWhenNonEmpty = statusWhenNonEmpty
self._items = items self.items = items
self.map = { $0 } self.map = { $0 }
} }
} }

View File

@@ -1,5 +1,5 @@
import SFSafeSymbols import SFSafeSymbols
import SwiftUICore import SwiftUI
enum IssueStatus { enum IssueStatus {
case nominal 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 { var body: some View {
GenericPropertyView(title: title, footer: footer) { GenericPropertyView(title: title, footer: footer) {
HStack { HStack {
Text(selectedFile?.id ?? "No file selected") Text(selectedFile?.identifier ?? "No file selected")
Spacer() Spacer()
Button("Select") { Button("Select") {
showFileSelectionSheet = true 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 { HStack {
Text(selectedImage?.id ?? "No file selected") Text(selectedImage?.identifier ?? "No file selected")
Spacer() Spacer()
Button("Select") { Button("Select") {
showSelectionSheet = true showSelectionSheet = true

View File

@@ -15,7 +15,7 @@ struct PagePropertyView: View {
var body: some View { var body: some View {
GenericPropertyView(title: title, footer: footer) { GenericPropertyView(title: title, footer: footer) {
HStack { HStack {
Text(selectedPage?.id ?? "No page selected") Text(selectedPage?.identifier ?? "No page selected")
Spacer() Spacer()
Button("Select") { Button("Select") {
showPageSelectionSheet = true showPageSelectionSheet = true

View File

@@ -16,9 +16,9 @@ struct TagDisplayView: View {
var body: some View { var body: some View {
FlowHStack { FlowHStack {
ForEach(tags, id: \.id) { tag in ForEach(tags, id: \.identifier) { tag in
TagView(text: tag.localized(in: language).name) TagView(text: tag.localized(in: language).name)
.foregroundStyle(.white) .foregroundStyle(.white)
} }
Button(action: { showTagPicker = true }) { Button(action: { showTagPicker = true }) {
Image(systemSymbol: .squareAndPencilCircleFill) Image(systemSymbol: .squareAndPencilCircleFill)

View File

@@ -27,7 +27,7 @@ struct TagPickerView: View {
Text("Select a tag to link to") Text("Select a tag to link to")
List(content.tags, selection: $newSelection) { tag in List(content.tags, selection: $newSelection) { tag in
let loc = tag.localized(in: language) let loc = tag.localized(in: language)
Text("\(loc.title) (\(tag.id))") Text("\(loc.title) (\(tag.identifier))")
.tag(tag) .tag(tag)
} }
.frame(minHeight: 300) .frame(minHeight: 300)

View File

@@ -15,7 +15,7 @@ struct TagPropertyView: View {
var body: some View { var body: some View {
GenericPropertyView(title: title, footer: footer) { GenericPropertyView(title: title, footer: footer) {
HStack { HStack {
Text(selectedTag?.id ?? "No tag selected") Text(selectedTag?.identifier ?? "No tag selected")
Spacer() Spacer()
Button("Select") { Button("Select") {
showTagSelectionSheet = true showTagSelectionSheet = true

View File

@@ -20,7 +20,7 @@ final class InsertableFileButton: ObservableObject {
""" """
icon: \(label.icon.rawValue) icon: \(label.icon.rawValue)
text: \(label.value) text: \(label.value)
file: \(file.id) file: \(file.identifier)
""" """
guard let downloadedFileName else { guard let downloadedFileName else {
return result return result
@@ -86,7 +86,7 @@ struct InsertableButtons: View, InsertableCommandView {
var id: String { var id: String {
switch self { switch self {
case .file(let file): case .file(let file):
return "file-\(file.file?.id ?? "none")" return "file-\(file.file?.identifier ?? "none")"
case .url(let url): case .url(let url):
return "url-\(url.url)" return "url-\(url.url)"
case .event(let event): case .event(let event):
@@ -161,7 +161,7 @@ private struct FileButtonView: View {
var body: some View { var body: some View {
HStack { HStack {
LabelEditingView(label: $content.label) LabelEditingView(label: $content.label)
Button("\(content.file?.id ?? "Select file")", action: { showFileSelectionSheet = true }) Button("\(content.file?.identifier ?? "Select file")", action: { showFileSelectionSheet = true })
OptionalTextField("", text: $content.downloadedFileName, prompt: "Downloaded file name") OptionalTextField("", text: $content.downloadedFileName, prompt: "Downloaded file name")
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }

View File

@@ -28,7 +28,7 @@ struct InsertableGallery: View, InsertableCommandView {
} }
return ( return (
["```\(GalleryBlock.blockId)"] + ["```\(GalleryBlock.blockId)"] +
images.map { $0.id } + images.map { $0.identifier } +
["```"] ["```"]
).joined(separator: "\n") ).joined(separator: "\n")
} }

View File

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

View File

@@ -45,11 +45,11 @@ struct InsertableLink: View, InsertableCommandView {
case .post, .tagOverview: case .post, .tagOverview:
return nil return nil
case .page: case .page:
return selectedPage?.id return selectedPage?.identifier
case .tag: case .tag:
return selectedTag?.id return selectedTag?.identifier
case .file: case .file:
return selectedFile?.id return selectedFile?.identifier
} }
} }

View File

@@ -40,8 +40,8 @@ struct InsertableRoute: View, InsertableCommandView {
return nil return nil
} }
var result = ["```route"] var result = ["```route"]
result.append("\(RouteBlock.Key.image.rawValue): \(selectedImage.id)") result.append("\(RouteBlock.Key.image.rawValue): \(selectedImage.identifier)")
result.append("\(RouteBlock.Key.file.rawValue): \(dataFile.id)") result.append("\(RouteBlock.Key.file.rawValue): \(dataFile.identifier)")
if components != Set(RouteStatisticType.allCases) { if components != Set(RouteStatisticType.allCases) {
let list = components let list = components
.map { $0.rawValue } .map { $0.rawValue }
@@ -57,7 +57,7 @@ struct InsertableRoute: View, InsertableCommandView {
if let mapTitle { if let mapTitle {
result.append("\(RouteBlock.Key.mapTitle.rawValue): \(mapTitle)") result.append("\(RouteBlock.Key.mapTitle.rawValue): \(mapTitle)")
} }
result.append("\n```") result.append("```")
return result.joined(separator: "\n") return result.joined(separator: "\n")
} }
} }

View File

@@ -0,0 +1,130 @@
import SwiftUI
import SFSafeSymbols
struct InsertableVideo: View, InsertableCommandView {
final class Model: ObservableObject, InsertableCommandModel {
@Published
var posterImage: FileResource?
@Published
var videoH265: FileResource?
@Published
var videoH264: FileResource?
@Published
var videoWebm: FileResource?
@Published
var controls: Bool = false
@Published
var autoplay: Bool = false
@Published
var loop: Bool = false
@Published
var muted: Bool = false
@Published
var playsinline = false
@Published
var preload = false
@Published
var preloadType: VideoBlock.Option.Preload = .metadata
var isReady: Bool {
videoH265 != nil || videoH264 != nil
}
init() {
}
var command: String? {
var lines: [String] = []
lines.append("```video")
if let posterImage {
lines.append("\(VideoBlock.Key.poster): \(posterImage.identifier)")
}
if let videoH265 {
lines.append("\(VideoBlock.Key.h265): \(videoH265.identifier)")
}
if let videoH264 {
lines.append("\(VideoBlock.Key.h264): \(videoH264.identifier)")
}
if let videoWebm {
lines.append("\(VideoBlock.Key.webm): \(videoWebm.identifier)")
}
if controls { lines.append(VideoBlock.Key.controls.rawValue) }
if autoplay { lines.append(VideoBlock.Key.autoplay.rawValue) }
if loop { lines.append(VideoBlock.Key.loop.rawValue) }
if muted { lines.append(VideoBlock.Key.muted.rawValue) }
if playsinline { lines.append(VideoBlock.Key.playsinline.rawValue) }
if preload {
lines.append("\(VideoBlock.Key.preload.rawValue):\(preloadType.rawValue)")
}
lines.append("```")
return lines.joined(separator: "\n")
}
}
static let title = "Video"
static let sheetTitle = "Insert video"
static let icon: SFSymbol = .movieclapper
@ObservedObject
private var model: Model
init(model: Model) {
self.model = model
}
var body: some View {
VStack {
FilePropertyView(
title: "Video (h265)",
footer: "Select the video encoded using h265",
selectedFile: $model.videoH265,
allowedType: .video)
FilePropertyView(
title: "Video (h264)",
footer: "Select the video encoded using h264",
selectedFile: $model.videoH264,
allowedType: .video)
FilePropertyView(
title: "Poster image",
footer: "Select the image to show as the poster",
selectedFile: $model.posterImage,
allowedType: .image)
HStack {
Toggle("controls", isOn: $model.controls)
Toggle("autoplay", isOn: $model.autoplay)
Toggle("loop", isOn: $model.loop)
Toggle("muted", isOn: $model.muted)
Toggle("playsinline", isOn: $model.playsinline)
Spacer()
}
HStack {
Toggle("preload", isOn: $model.preload)
Picker("", selection: $model.preloadType) {
ForEach(VideoBlock.Option.Preload.allCases, id: \.rawValue) { type in
Text("\(type.rawValue)").tag(type)
}
}
.disabled(!model.preload)
.frame(maxWidth: 100)
Spacer()
}
}
.toggleStyle(.checkbox)
}
}

View File

@@ -4,9 +4,8 @@ struct InsertableItemsView: View {
var body: some View { var body: some View {
HStack { HStack {
Text("Commands")
.font(.headline)
InsertableView<InsertableImage>() InsertableView<InsertableImage>()
InsertableView<InsertableVideo>()
InsertableView<InsertableGallery>() InsertableView<InsertableGallery>()
InsertableView<InsertableLabels>() InsertableView<InsertableLabels>()
InsertableView<InsertableButtons>() InsertableView<InsertableButtons>()

View File

@@ -5,12 +5,21 @@ struct LabelCreationView: View {
@Environment(\.colorScheme) @Environment(\.colorScheme)
private var colorScheme private var colorScheme
@Environment(\.language)
private var language
@Binding @Binding
var labels: [ContentLabel] var labels: [ContentLabel]
@State
private var showWorkoutSelection = false
@State
private var selectedWorkouts: [FileResource] = []
var body: some View { var body: some View {
List { List {
ForEach($labels) { label in ForEach($labels, id: \.icon) { label in
HStack { HStack {
Button(action: { remove(label.wrappedValue) }) { Button(action: { remove(label.wrappedValue) }) {
Image(systemSymbol: .minusCircleFill) Image(systemSymbol: .minusCircleFill)
@@ -25,20 +34,35 @@ struct LabelCreationView: View {
.cornerRadius(8) .cornerRadius(8)
} }
.onMove(perform: moveLabel) .onMove(perform: moveLabel)
Button("Load workout") { showWorkoutSelection = true }
.padding(.vertical, 2)
Button("Add new label", action: addLabel) Button("Add new label", action: addLabel)
.padding(.vertical, 2) .padding(.vertical, 2)
} }
.frame(minHeight: 250) .frame(minHeight: 250)
.sheet(isPresented: $showWorkoutSelection) {
MultiFileSelectionView(
selectedFiles: $selectedWorkouts,
allowedType: .route)
}
.onChange(of: selectedWorkouts) {
let workouts = selectedWorkouts.compactMap { $0.routeOverview }
guard !workouts.isEmpty else {
return
}
let overview = RouteOverview.combine(workouts)
overview.update(labels: &labels, language: language)
}
} }
private func addLabel() { private func addLabel() {
var label = ContentLabel(icon: .statisticsTime, value: "Value") for icon in PageIcon.allCases {
var number = 0 if labels.contains(where: { $0.icon == icon }) {
while labels.contains(label) { continue
number += 1 }
label.value = "Value \(number)" labels.append(.init(icon: icon, value: "Value"))
return
} }
labels.append(label)
} }
private func remove(_ label: ContentLabel) { private func remove(_ label: ContentLabel) {

View File

@@ -35,7 +35,7 @@ struct PageContentResultsView: View {
TextWithSymbol( TextWithSymbol(
symbol: $0.type.category.symbol, symbol: $0.type.category.symbol,
color: .blue, color: .blue,
text: $0.id) text: $0.identifier)
} }
+ results.missingFiles.keys.map { + results.missingFiles.keys.map {
TextWithSymbol( TextWithSymbol(

View File

@@ -32,7 +32,7 @@ struct PageContentView: View {
if page.isExternalUrl { if page.isExternalUrl {
VStack { VStack {
PageTitleView(page: page.localized(in: language)) PageTitleView(page: page.localized(in: language))
.id(page.id + language.rawValue) .id(page.identifier + language.rawValue)
Spacer() Spacer()
Text("No content available for external page") Text("No content available for external page")
.font(.title) .font(.title)
@@ -42,10 +42,10 @@ struct PageContentView: View {
} else { } else {
VStack(alignment: .leading) { VStack(alignment: .leading) {
PageTitleView(page: page.localized(in: language)) PageTitleView(page: page.localized(in: language))
.id(page.id + language.rawValue) .id(page.identifier + language.rawValue)
TagDisplayView(tags: $page.tags) TagDisplayView(tags: $page.tags)
LocalizedPageContentView(page: page, language: language) LocalizedPageContentView(page: page, language: language)
.id(page.id + language.rawValue) .id(page.identifier + language.rawValue)
} }
.padding() .padding()
} }

View File

@@ -9,6 +9,9 @@ struct PageDetailView: View {
@EnvironmentObject @EnvironmentObject
private var content: Content private var content: Content
@EnvironmentObject
private var selection: SelectedContent
@ObservedObject @ObservedObject
private var page: Page private var page: Page
@@ -27,10 +30,11 @@ struct PageDetailView: View {
title: "Page", title: "Page",
text: "A page contains longer content") text: "A page contains longer content")
IdPropertyView( IdPropertyView(
id: $page.id, id: $page.identifier,
footer: "The page id is used to link to it internally.", footer: "The page id is used to link to it internally.",
validation: page.isValid, validation: page.isValid,
update: { page.update(id: $0) }) update: { page.update(id: $0) })
.id(page.id)
OptionalStringPropertyView( OptionalStringPropertyView(
title: "External url", title: "External url",
@@ -72,11 +76,21 @@ struct PageDetailView: View {
isExternalPage: page.isExternalUrl, isExternalPage: page.isExternalUrl,
page: page.localized(in: language), page: page.localized(in: language),
transferImage: transferImage) transferImage: transferImage)
.id(page.id + language.rawValue) .id(page.identifier + language.rawValue)
ColoredButton(delete: deletePage)
} }
.padding() .padding()
} }
} }
private func deletePage() {
guard content.storage.delete(page: page.identifier) else {
print("Page '\(page.identifier)': Failed to delete file in content folder")
return
}
content.remove(page)
selection.remove(page)
}
} }
extension PageDetailView: MainContentView { extension PageDetailView: MainContentView {

View File

@@ -7,5 +7,6 @@ struct LinkedPageTagView: View {
var body: some View { var body: some View {
TagDisplayView(tags: $page.tags) TagDisplayView(tags: $page.tags)
.id(page.id)
} }
} }

View File

@@ -27,7 +27,7 @@ struct PagePickerView: View {
Text("Select a page to link to") Text("Select a page to link to")
List(content.pages, selection: $newSelection) { page in List(content.pages, selection: $newSelection) { page in
let loc = page.localized(in: language) let loc = page.localized(in: language)
Text("\(loc.title) (\(page.id))") Text("\(loc.title) (\(page.identifier))")
.tag(page) .tag(page)
} }
.frame(minHeight: 300) .frame(minHeight: 300)

View File

@@ -15,11 +15,25 @@ struct PostContentView: View {
} }
var body: some View { var body: some View {
LocalizedPostContentView( let localized = post.localized(in: language)
post: post.localized(in: language), let other = post.localized(in: language.next)
other: post.localized(in: language.next), VStack(alignment: .leading) {
tags: $post.tags, PostImagesView(
page: $post.linkedPage) post: localized,
other: other)
PostTitleView(post: localized)
if let page = post.linkedPage {
LinkedPageTagView(page: page)
} else {
TagDisplayView(tags: $post.tags)
}
PostLabelsView(
post: post,
localized: localized,
other: other)
PostTextView(post: localized)
}
.padding()
} }
} }

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SFSafeSymbols
struct PostDetailView: View { struct PostDetailView: View {
@@ -32,15 +33,29 @@ struct PostDetailView: View {
title: "Post", title: "Post",
text: "Posts capture quick updates and can link to pages") text: "Posts capture quick updates and can link to pages")
if post.linkedPage == nil { if let page = post.linkedPage {
Button("Create page", action: createPageFromPost) ColoredButton(
icon: .document,
text: "Show page",
fillColor: .blue,
textColor: .white,
action: { showPage(page) })
} else {
ColoredButton(
icon: .documentBadgePlus,
text: "Create page",
fillColor: .blue,
textColor: .white,
action: createPageFromPost)
} }
IdPropertyView( IdPropertyView(
id: $post.id, id: $post.identifier,
footer: "The id is used to link to post and store them", footer: "The id is used to link to post and store them",
validation: post.isValid, validation: post.isValid,
update: { post.update(id: $0) }) update: { post.update(id: $0) })
.id(post.id)
BoolPropertyView( BoolPropertyView(
title: "Draft", title: "Draft",
@@ -63,6 +78,7 @@ struct PostDetailView: View {
selectedPage: $post.linkedPage, selectedPage: $post.linkedPage,
footer: "The page to open when clicking on the post") footer: "The page to open when clicking on the post")
.onChange(of: post.linkedPage) { oldValue, newValue in .onChange(of: post.linkedPage) { oldValue, newValue in
if newValue != nil { if newValue != nil {
post.tags = [] post.tags = []
} else { } else {
@@ -73,9 +89,16 @@ struct PostDetailView: View {
} }
} }
FilesPropertyView(
title: "Associated workout",
footer: "The workout file to display with this post",
selectedFiles: $post.associatedWorkouts,
allowedType: .route)
LocalizedPostDetailView( LocalizedPostDetailView(
post: post.localized(in: language), post: post.localized(in: language),
transferImage: transferImage) transferImage: transferImage)
ColoredButton(delete: deletePost)
} }
.padding() .padding()
} }
@@ -90,6 +113,20 @@ struct PostDetailView: View {
selection.tab = .pages selection.tab = .pages
} }
} }
private func showPage(_ page: Page) {
selection.page = page
selection.tab = .pages
}
private func deletePost() {
guard content.storage.delete(post: post.identifier) else {
print("Post '\(post.identifier)': Failed to delete file in content folder")
return
}
content.remove(post)
selection.remove(post)
}
} }
extension PostDetailView: MainContentView { extension PostDetailView: MainContentView {

View File

@@ -18,7 +18,7 @@ struct PostImageView: View {
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(height: 100) .frame(height: 100)
Text(image.id) Text(image.identifier)
.font(.title) .font(.title)
Text("Failed to load image") Text("Failed to load image")
.font(.body) .font(.body)
@@ -32,7 +32,7 @@ struct PostImageView: View {
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(height: 100) .frame(height: 100)
Text(image.id) Text(image.identifier)
.font(.title) .font(.title)
Button("Generate preview") { Button("Generate preview") {
generateVideoPreview(image) generateVideoPreview(image)
@@ -48,7 +48,7 @@ struct PostImageView: View {
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(height: 100) .frame(height: 100)
Text(image.id) Text(image.identifier)
.font(.title) .font(.title)
Text("Invalid media type") Text("Invalid media type")
.font(.body) .font(.body)

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