diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9a44646 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.build/ +.swiftpm/ +DerivedData diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b448c62 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +# ================================ +# Build image +# ================================ +FROM swift:5.2-focal as build + +# Install OS updates and, if needed, sqlite3 +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +# Set up a build area +WORKDIR /build + +# First just resolve dependencies. +# This creates a cached layer that can be reused +# as long as your Package.swift/Package.resolved +# files do not change. +COPY ./Package.* ./ +RUN swift package resolve + +# Copy entire repo into container +COPY . . + +# Build everything, with optimizations and test discovery +RUN swift build --enable-test-discovery -c release + +# Switch to the staging area +WORKDIR /staging + +# Copy main executable to staging area +RUN cp "$(swift build --package-path /build -c release --show-bin-path)/orchardnestd" ./ + +# Uncomment the next line if you need to load resources from the `Public` directory. +# Ensure that by default, neither the directory nor any of its contents are writable. +RUN mv /build/Public ./Public && chmod -R a-w ./Public + +# ================================ +# Run image +# ================================ +FROM swift:5.2-focal-slim + +# Make sure all system packages are up to date. +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true && \ + apt-get -q update && apt-get -q dist-upgrade -y && rm -r /var/lib/apt/lists/* + +# Create a vapor user and group with /app as its home directory +RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor + +# Switch to the new home directory +WORKDIR /app + +# Copy built executable and any staged resources from builder +COPY --from=build --chown=vapor:vapor /staging /app + +# Ensure all further commands run as the vapor user +USER vapor:vapor + +# Let Docker bind to port 8080 +EXPOSE 8080 + +# Start the Vapor service when the image is run, default to listening on 8080 in production environment +ENTRYPOINT ["./orchardnestd"] +CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] diff --git a/Documentation/Reference/enums/EntryCategory.md b/Documentation/Reference/enums/EntryCategory.md index ef6059f..c375b83 100644 --- a/Documentation/Reference/enums/EntryCategory.md +++ b/Documentation/Reference/enums/EntryCategory.md @@ -37,10 +37,10 @@ case marketing case newsletters ``` -### `podcasts(_:)` +### `podcasts(_:_:)` ```swift -case podcasts(URL) +case podcasts(URL, Int) ``` ### `updates` @@ -49,10 +49,10 @@ case podcasts(URL) case updates ``` -### `youtube(_:)` +### `youtube(_:_:)` ```swift -case youtube(String) +case youtube(String, Int) ``` ## Properties @@ -63,16 +63,16 @@ public var type: EntryCategoryType ``` ## Methods -### `init(podcastEpisodeAtURL:)` +### `init(podcastEpisodeAtURL:withSeconds:)` ```swift -public init(podcastEpisodeAtURL url: URL) +public init(podcastEpisodeAtURL url: URL, withSeconds seconds: Int) ``` -### `init(youtubeVideoWithID:)` +### `init(youtubeVideoWithID:withSeconds:)` ```swift -public init(youtubeVideoWithID id: String) +public init(youtubeVideoWithID id: String, withSeconds seconds: Int) ``` ### `init(type:)` diff --git a/Documentation/Reference/enums/Errors.md b/Documentation/Reference/enums/Errors.md new file mode 100644 index 0000000..6b0f71c --- /dev/null +++ b/Documentation/Reference/enums/Errors.md @@ -0,0 +1,32 @@ +**ENUM** + +# `Errors` + +```swift +public enum Errors: Error +``` + +## Cases +### `notBeginWithP` + +```swift +case notBeginWithP +``` + +### `timePartNotBeginWithT` + +```swift +case timePartNotBeginWithT +``` + +### `unknownElement` + +```swift +case unknownElement +``` + +### `discontinuous` + +```swift +case discontinuous +``` diff --git a/Documentation/Reference/extensions/EntryItem.md b/Documentation/Reference/extensions/EntryItem.md index f7d74d2..756c502 100644 --- a/Documentation/Reference/extensions/EntryItem.md +++ b/Documentation/Reference/extensions/EntryItem.md @@ -6,6 +6,12 @@ public extension EntryItem ``` ## Properties +### `seconds` + +```swift +var seconds: Int? +``` + ### `podcastEpisodeURL` ```swift @@ -23,3 +29,9 @@ var youtubeID: String? ```swift var twitterShareLink: String ``` + +### `fallbackImageURL` + +```swift +var fallbackImageURL: URL? +``` diff --git a/Documentation/Reference/structs/FeedItem.md b/Documentation/Reference/structs/FeedItem.md index 16e6849..b8fdff9 100644 --- a/Documentation/Reference/structs/FeedItem.md +++ b/Documentation/Reference/structs/FeedItem.md @@ -61,6 +61,12 @@ public let ytId: String? public let audio: URL? ``` +### `duration` + +```swift +public let duration: TimeInterval? +``` + ### `published` ```swift diff --git a/Documentation/Reference/structs/YouTubeItem.md b/Documentation/Reference/structs/YouTubeItem.md new file mode 100644 index 0000000..fdd1f3e --- /dev/null +++ b/Documentation/Reference/structs/YouTubeItem.md @@ -0,0 +1,20 @@ +**STRUCT** + +# `YouTubeItem` + +```swift +public struct YouTubeItem: Decodable +``` + +## Properties +### `contentDetails` + +```swift +public let contentDetails: YouTubeItemContentDetails +``` + +### `id` + +```swift +public let id: String +``` diff --git a/Documentation/Reference/structs/YouTubeItemContentDetails.md b/Documentation/Reference/structs/YouTubeItemContentDetails.md new file mode 100644 index 0000000..986abdd --- /dev/null +++ b/Documentation/Reference/structs/YouTubeItemContentDetails.md @@ -0,0 +1,27 @@ +**STRUCT** + +# `YouTubeItemContentDetails` + +```swift +public struct YouTubeItemContentDetails: Decodable +``` + +## Properties +### `duration` + +```swift +public let duration: TimeInterval +``` + +## Methods +### `init(from:)` + +```swift +public init(from decoder: Decoder) throws +``` + +#### Parameters + +| Name | Description | +| ---- | ----------- | +| decoder | The decoder to read data from. | \ No newline at end of file diff --git a/Documentation/Reference/structs/YouTubeResponse.md b/Documentation/Reference/structs/YouTubeResponse.md new file mode 100644 index 0000000..fcd2edf --- /dev/null +++ b/Documentation/Reference/structs/YouTubeResponse.md @@ -0,0 +1,14 @@ +**STRUCT** + +# `YouTubeResponse` + +```swift +public struct YouTubeResponse: Decodable +``` + +## Properties +### `items` + +```swift +public let items: [YouTubeItem] +``` diff --git a/Package.swift b/Package.swift index b6b2ce5..8c24610 100644 --- a/Package.swift +++ b/Package.swift @@ -28,8 +28,7 @@ let package = Package( .package(url: "https://github.com/JohnSundell/Ink.git", from: "0.1.0"), // dev .package(url: "https://github.com/shibapm/Komondor", from: "1.0.5"), - .package(url: "https://github.com/eneko/SourceDocs", from: "1.2.1"), - .package(url: "https://github.com/shibapm/Rocket", from: "0.1.0") + .package(url: "https://github.com/eneko/SourceDocs", from: "1.2.1") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. diff --git a/Public/robots.txt b/Public/robots.txt new file mode 100644 index 0000000..e69de29 diff --git a/Public/styles/style.css b/Public/styles/style.css index de7b246..b57a1bc 100644 --- a/Public/styles/style.css +++ b/Public/styles/style.css @@ -44,6 +44,9 @@ ul.articles li > .summary { padding: 8px 0px; } +ul.articles li > .length { + display: none; +} ul.articles li > .publishedAt, ul.articles li > .author{ font-size: 0.8em; } @@ -76,6 +79,36 @@ ul.articles li > .video-content iframe { display: none; } +ul.articles li > .video-content { + max-width: 100%; + width: 320px; + height: 180px; +} + +ul.articles li > .featured-image { + background-size: cover; + width: 320px; + background-position: center; + height: 180px; + max-width: 100%; + background-repeat: no-repeat; +} + +@media (min-width: 40.0rem) { + ul.articles li > .featured-image { + float: right; + width: 320px; + height: 180px; + margin-left: 1em; + background-size: contain; + } + + ul.articles li > .video-content { + float: right; + margin-left: 1em; + } +} + ul.articles li > .social-share > ul{ list-style: none; display: inline-block; @@ -125,3 +158,149 @@ ul.articles li > ul.podcast-players > li img+div { ul.articles li > ul.podcast-players > li img+div > div:first-child { font-size: 0.7em; } + +body.category.podcasts ul.articles { + margin: auto; +} +body.category.podcasts ul.articles li > .featured-image { + float: right; + /* float: right; */ + position: absolute; + top: 0; + width: 200px; + left: 0px; + height: 200px; + margin-left: 1em; + /* margin-left: 1em; */ + background-size: contain; +} +body.category.podcasts h3 { + font-size: 2.8rem; + font-size: 1.5rem; + line-height: 1.3; +} +body.category.podcasts .title { + font-size: 0.5em; + font-weight: bold; + height: 4em; + display: block; + text-overflow: ellipsis; + overflow: hidden; + font-weight: 400; +} +body.category.podcasts .title h3 { + font-weight: 400; + +} +body.category.podcasts .title i{ + display: none; +} +body.category.podcasts .blog-post { + padding: 1em 0px; + /* padding: 1em 0px; */ + position: relative; + padding-top: 208px; + width: 200px; + font-size: 0.5em; + float: left; + margin-right: 50px; +} +body.category.podcasts .summary { + display: none; +} +body.category.podcasts ul.articles li > .length { + display: inline-block; + float: right; +} + +body.category.podcasts audio{ + width: 200px; +} +body.category.podcasts .blog-post .publishedAt { + float: left; +} +body.category.podcasts .blog-post .podcast-players li a { + border: none; + width: auto; +} + +body.category.podcasts .blog-post .podcast-players li a > div > div { + display: none; +} + +body.category.podcasts ul.articles li.blog-post .author { + height: 50px; +} +body.category.podcasts ul.articles li.blog-post .author .a { + clear: both; + display: block; +} + + +body.category.youtube ul.articles { + margin: auto; +} +body.category.youtube ul.articles li > .video-content { + position: absolute; + top: 0; + left: 0; +} + +body.category.youtube h3 { + font-size: 1.5rem; + line-height: 1.3; +} +body.category.youtube .title { + font-size: 0.5em; + font-weight: bold; + height: 5em; + display: block; + text-overflow: ellipsis; + overflow: hidden; + font-weight: 400; +} +body.category.youtube .title h3 { + font-weight: 400; + font-size: 1.75rem; + +} +body.category.youtube .title i{ + display: none; +} +body.category.youtube .blog-post { + padding: 1em 0px; + /* padding: 1em 0px; */ + position: relative; + padding-top: 184px; + width: 320px; + font-size: 0.5em; + float: left; + margin-right: 25px; +} +body.category.youtube .summary { + display: none; +} +body.category.youtube ul.articles li > .length { + display: inline-block; + float: right; +} + +body.category.youtube .blog-post .publishedAt { + float: left; +} +body.category.youtube .blog-post .podcast-players li a { + border: none; + width: auto; +} + +body.category.youtube .blog-post .podcast-players li a > div > div { + display: none; +} + +body.category.youtube ul.articles li.blog-post .author { + height: 50px; +} +body.category.youtube ul.articles li.blog-post .author .a { + clear: both; + display: block; +} diff --git a/Public/test.html b/Public/test.html deleted file mode 100644 index 6f7317f..0000000 --- a/Public/test.html +++ /dev/null @@ -1,804 +0,0 @@ - - - - OrchardNest - Swift Articles and News - - - - - - - - - - - - - - - - - -
- -
-

-  OrchardNest -

-
-
-

Swift Articles and News

-
-
-
- -
- - -
-
- - \ No newline at end of file diff --git a/Sources/OrchardNestKit/Component.swift b/Sources/OrchardNestKit/Component.swift new file mode 100644 index 0000000..7aed79d --- /dev/null +++ b/Sources/OrchardNestKit/Component.swift @@ -0,0 +1,87 @@ +import Foundation + +enum Component { + case second + case minute + case hour + case day + case week + case month + case year + + func timeInterval() -> TimeInterval { + switch self { + case .second: + return .second + case .minute: + return .minute + case .hour: + return .hour + case .day: + return .day + case .week: + return .week + case .month: + return .month + case .year: + return .year + } + } + + func stepDown() -> (duration: Int, component: Component) { + switch self { + case .year: + return (duration: 12, component: .month) + case .month: + return (duration: 30, component: .day) + case .week: + return (duration: 7, component: .day) + case .day: + return (duration: 24, component: .hour) + case .hour: + return (duration: 60, component: .minute) + case .minute: + return (duration: 60, component: .second) + case .second: + return (duration: 1, component: .second) + } + } + + func toCalendarComponent() -> Calendar.Component { + switch self { + case .year: + return .year + case .month: + return .month + case .week: + return .day + case .day: + return .day + case .hour: + return .hour + case .minute: + return .minute + case .second: + return .second + } + } + + func value(duration: Double) -> [ComponentDuration] { + var correctedDuration = duration + if self == .week { + correctedDuration *= 7 + } + let intValue = Int(correctedDuration.rounded(.down)) + var output: [ComponentDuration] = [] + + output.append(ComponentDuration(duration: intValue, component: toCalendarComponent())) + let remainedDuration = duration - Double(intValue) + if remainedDuration > 0.0001 { + let step = stepDown() + let recalculatedRemainedDuration = Double(step.duration) * remainedDuration + let intRecalculatedRemainedDuration = Int(recalculatedRemainedDuration.rounded(.down)) + output.append(ComponentDuration(duration: intRecalculatedRemainedDuration, component: step.component.toCalendarComponent())) + } + return output + } +} diff --git a/Sources/OrchardNestKit/ComponentDuration.swift b/Sources/OrchardNestKit/ComponentDuration.swift new file mode 100644 index 0000000..77e5cb0 --- /dev/null +++ b/Sources/OrchardNestKit/ComponentDuration.swift @@ -0,0 +1,6 @@ +import Foundation + +struct ComponentDuration { + let duration: Int + let component: Calendar.Component +} diff --git a/Sources/OrchardNestKit/EntryCategory.swift b/Sources/OrchardNestKit/EntryCategory.swift new file mode 100644 index 0000000..5fcc16f --- /dev/null +++ b/Sources/OrchardNestKit/EntryCategory.swift @@ -0,0 +1,90 @@ +import Foundation + +public enum EntryCategory: Codable { + public init(podcastEpisodeAtURL url: URL, withSeconds seconds: Int) { + self = .podcasts(url, seconds) + } + + public init(youtubeVideoWithID id: String, withSeconds seconds: Int) { + self = .youtube(id, seconds) + } + + public init(type: EntryCategoryType) throws { + switch type { + case .companies: self = .companies + case .design: self = .design + case .development: self = .development + case .marketing: self = .marketing + case .newsletters: self = .newsletters + case .updates: self = .updates + default: + throw IncompleteCategoryType(type: type) + } + } + + public init(from decoder: Decoder) throws { + let codable = try EntryCategoryCodable(from: decoder) + + switch codable.type { + case .companies: self = .companies + case .design: self = .design + case .development: self = .development + case .marketing: self = .marketing + case .newsletters: self = .newsletters + case .updates: self = .updates + case .podcasts: + guard let url = codable.value.flatMap(URL.init(string:)), let seconds = codable.seconds else { + throw DecodingError.valueNotFound(URL.self, DecodingError.Context(codingPath: [], debugDescription: "")) + } + self = .podcasts(url, seconds) + case .youtube: + guard let id = codable.value, let seconds = codable.seconds else { + throw DecodingError.valueNotFound(URL.self, DecodingError.Context(codingPath: [], debugDescription: "")) + } + self = .youtube(id, seconds) + } + } + + public func encode(to encoder: Encoder) throws { + let codable = EntryCategoryCodable(type: type, value: value, seconds: seconds) + try codable.encode(to: encoder) + } + + case companies + case design + case development + case marketing + case newsletters + case podcasts(URL, Int) + case updates + case youtube(String, Int) + + public var type: EntryCategoryType { + switch self { + case .companies: return .companies + case .design: return .design + case .development: return .development + case .marketing: return .marketing + case .newsletters: return .newsletters + case .podcasts: return .podcasts + case .updates: return .updates + case .youtube: return .youtube + } + } + + var value: String? { + switch self { + case let .podcasts(url, _): return url.absoluteString + case let .youtube(id, _): return id + default: return nil + } + } + + var seconds: Int? { + switch self { + case let .podcasts(_, seconds): return seconds + case let .youtube(_, seconds): return seconds + default: return nil + } + } +} diff --git a/Sources/OrchardNestKit/EntryCategoryCodable.swift b/Sources/OrchardNestKit/EntryCategoryCodable.swift new file mode 100644 index 0000000..b123298 --- /dev/null +++ b/Sources/OrchardNestKit/EntryCategoryCodable.swift @@ -0,0 +1,7 @@ +import Foundation + +struct EntryCategoryCodable: Codable { + let type: EntryCategoryType + let value: String? + let seconds: Int? +} diff --git a/Sources/OrchardNestKit/EntryCategoryType.swift b/Sources/OrchardNestKit/EntryCategoryType.swift new file mode 100644 index 0000000..03ab33d --- /dev/null +++ b/Sources/OrchardNestKit/EntryCategoryType.swift @@ -0,0 +1,12 @@ +import Foundation + +public enum EntryCategoryType: String, Codable { + case companies + case design + case development + case marketing + case newsletters + case podcasts + case updates + case youtube +} diff --git a/Sources/OrchardNestKit/EntryItem.swift b/Sources/OrchardNestKit/EntryItem.swift index 26400c5..5cc40fa 100644 --- a/Sources/OrchardNestKit/EntryItem.swift +++ b/Sources/OrchardNestKit/EntryItem.swift @@ -1,106 +1,5 @@ import Foundation -struct IncompleteCategoryType: Error { - let type: EntryCategoryType -} - -public enum EntryCategoryType: String, Codable { - case companies - case design - case development - case marketing - case newsletters - case podcasts - case updates - case youtube -} - -struct EntryCategoryCodable: Codable { - let type: EntryCategoryType - let value: String? -} - -public enum EntryCategory: Codable { - public init(podcastEpisodeAtURL url: URL) { - self = .podcasts(url) - } - - public init(youtubeVideoWithID id: String) { - self = .youtube(id) - } - - public init(type: EntryCategoryType) throws { - switch type { - case .companies: self = .companies - case .design: self = .design - case .development: self = .development - case .marketing: self = .marketing - case .newsletters: self = .newsletters - case .updates: self = .updates - default: - throw IncompleteCategoryType(type: type) - } - } - - public init(from decoder: Decoder) throws { - let codable = try EntryCategoryCodable(from: decoder) - - switch codable.type { - case .companies: self = .companies - case .design: self = .design - case .development: self = .development - case .marketing: self = .marketing - case .newsletters: self = .newsletters - case .updates: self = .updates - case .podcasts: - guard let url = codable.value.flatMap(URL.init(string:)) else { - throw DecodingError.valueNotFound(URL.self, DecodingError.Context(codingPath: [], debugDescription: "")) - } - self = .podcasts(url) - case .youtube: - guard let id = codable.value else { - throw DecodingError.valueNotFound(URL.self, DecodingError.Context(codingPath: [], debugDescription: "")) - } - self = .youtube(id) - } - } - - public func encode(to encoder: Encoder) throws { - let codable = EntryCategoryCodable(type: type, value: value) - try codable.encode(to: encoder) - } - - case companies - case design - case development - case marketing - case newsletters - case podcasts(URL) - case updates - case youtube(String) - - public var type: EntryCategoryType { - switch self { - case .companies: return .companies - case .design: return .design - case .development: return .development - case .marketing: return .marketing - case .newsletters: return .newsletters - case .podcasts: return .podcasts - case .updates: return .updates - case .youtube: return .youtube - } - } - - var value: String? { - switch self { - case let .podcasts(url): return url.absoluteString - case let .youtube(id): return id - default: return nil - } - } -} - public struct EntryItem: Codable { public let id: UUID public let channel: EntryChannel @@ -134,15 +33,25 @@ public struct EntryItem: Codable { } public extension EntryItem { + var seconds: Int? { + if case let .youtube(_, seconds) = category { + return seconds + } else + if case let .podcasts(_, seconds) = category { + return seconds + } + return nil + } + var podcastEpisodeURL: URL? { - if case let .podcasts(url) = category { + if case let .podcasts(url, _) = category { return url } return nil } var youtubeID: String? { - if case let .youtube(id) = category { + if case let .youtube(id, _) = category { return id } return nil @@ -152,4 +61,8 @@ public extension EntryItem { let text = title + (channel.twitterHandle.map { " from @\($0)" } ?? "") return "https://twitter.com/intent/tweet?text=\(text)&via=orchardnest&url=\(url)" } + + var fallbackImageURL: URL? { + return imageURL ?? channel.imageURL + } } diff --git a/Sources/OrchardNestKit/Errors.swift b/Sources/OrchardNestKit/Errors.swift new file mode 100644 index 0000000..aa4cb0d --- /dev/null +++ b/Sources/OrchardNestKit/Errors.swift @@ -0,0 +1,6 @@ +public enum Errors: Error { + case notBeginWithP + case timePartNotBeginWithT + case unknownElement + case discontinuous +} diff --git a/Sources/OrchardNestKit/FeedChannel.swift b/Sources/OrchardNestKit/FeedChannel.swift index 2145f4c..b2b98f8 100644 --- a/Sources/OrchardNestKit/FeedChannel.swift +++ b/Sources/OrchardNestKit/FeedChannel.swift @@ -1,15 +1,6 @@ import FeedKit import Foundation -extension URL { - func ensureAbsolute(_ baseURL: URL) -> URL { - guard host == nil else { - return self - } - return URL(string: relativeString, relativeTo: baseURL) ?? self - } -} - public struct FeedChannel: Codable { static let youtubeImgBaseURL = URL(string: "https://img.youtube.com/vi/")! public static func imageURL(fromYoutubeId ytId: String) -> URL { @@ -74,7 +65,7 @@ public struct FeedChannel: Codable { url: url, image: image, ytId: nil, - audio: nil, + audio: nil, duration: nil, published: published ) } ?? [FeedItem]() @@ -128,6 +119,7 @@ public struct FeedChannel: Codable { image: image, ytId: nil, audio: enclosure?.audioURL, + duration: item.iTunes?.iTunesDuration, published: published ) } ?? [FeedItem]() @@ -193,7 +185,7 @@ public struct FeedChannel: Codable { url: url, image: media?.compactMap { $0.imageURL }.first, ytId: ytId, - audio: media?.compactMap { $0.audioURL }.first, + audio: media?.compactMap { $0.audioURL }.first, duration: nil, published: published ) } ?? [FeedItem]() diff --git a/Sources/OrchardNestKit/FeedItem.swift b/Sources/OrchardNestKit/FeedItem.swift index 0864bd2..d920731 100644 --- a/Sources/OrchardNestKit/FeedItem.swift +++ b/Sources/OrchardNestKit/FeedItem.swift @@ -10,5 +10,6 @@ public struct FeedItem: Codable { public let image: URL? public let ytId: String? public let audio: URL? + public let duration: TimeInterval? public let published: Date } diff --git a/Sources/OrchardNestKit/IncompleteCategoryType.swift b/Sources/OrchardNestKit/IncompleteCategoryType.swift new file mode 100644 index 0000000..e23b097 --- /dev/null +++ b/Sources/OrchardNestKit/IncompleteCategoryType.swift @@ -0,0 +1,3 @@ +struct IncompleteCategoryType: Error { + let type: EntryCategoryType +} diff --git a/Sources/OrchardNestKit/TimeInterval.swift b/Sources/OrchardNestKit/TimeInterval.swift new file mode 100644 index 0000000..a90c22d --- /dev/null +++ b/Sources/OrchardNestKit/TimeInterval.swift @@ -0,0 +1,90 @@ +import Foundation + +extension TimeInterval { + static let second = 1.0 + static let minute = 60 * TimeInterval.second + static let hour = 60 * TimeInterval.minute + static let day = 24 * TimeInterval.hour + static let week = 7 * TimeInterval.day + static let month = 30 * TimeInterval.day + static let year = 365.25 * TimeInterval.day +} + +extension TimeInterval { + // swiftlint:disable:next cyclomatic_complexity + init(iso8601: String) throws { + let value = iso8601 + guard value.hasPrefix("P") else { + throw Errors.notBeginWithP + } + // originalValue = value + var timeInterval: TimeInterval = 0 + var numberValue: String = "" + let numbers = Set("0123456789.,") + var isTimePart = false + + var dateComponents = DateComponents( + calendar: Calendar.current, + timeZone: TimeZone.current, + era: nil, year: 0, month: 0, day: 0, hour: 0, minute: 0, second: 0, nanosecond: nil, + weekday: nil, weekdayOrdinal: nil, quarter: nil, weekOfMonth: nil, weekOfYear: nil, yearForWeekOfYear: nil + ) + + func addTimeInterval(base: Component) { + guard let value = Double(numberValue.replacingOccurrences(of: ",", with: ".")) else { + numberValue = "" + return + } + timeInterval += value * base.timeInterval() + numberValue = "" + + let components = base.value(duration: value) + for component in components { + var currentValue = dateComponents.value(for: component.component) ?? 0 + currentValue += component.duration + dateComponents.setValue(currentValue, for: component.component) + } + } + + for char in value { + switch char { + case "P": + continue + case "T": + isTimePart = true + case _ where numbers.contains(char): + numberValue.append(char) + case "D": + addTimeInterval(base: .day) + case "Y": + addTimeInterval(base: .year) + case "M": + if isTimePart { + addTimeInterval(base: .minute) + } else { + addTimeInterval(base: .month) + } + case "W": + addTimeInterval(base: .week) + case "H": + if isTimePart { + addTimeInterval(base: .hour) + } else { + throw Errors.timePartNotBeginWithT + } + case "S": + if isTimePart { + addTimeInterval(base: .second) + } else { + throw Errors.timePartNotBeginWithT + } + default: + throw Errors.unknownElement + } + } + if numberValue.count > 0 { + throw Errors.discontinuous + } + self = timeInterval + } +} diff --git a/Sources/OrchardNestKit/URL.swift b/Sources/OrchardNestKit/URL.swift new file mode 100644 index 0000000..2627497 --- /dev/null +++ b/Sources/OrchardNestKit/URL.swift @@ -0,0 +1,10 @@ +import Foundation + +extension URL { + func ensureAbsolute(_ baseURL: URL) -> URL { + guard host == nil else { + return self + } + return URL(string: relativeString, relativeTo: baseURL) ?? self + } +} diff --git a/Sources/OrchardNestKit/YouTubeItem.swift b/Sources/OrchardNestKit/YouTubeItem.swift new file mode 100644 index 0000000..7e21684 --- /dev/null +++ b/Sources/OrchardNestKit/YouTubeItem.swift @@ -0,0 +1,4 @@ +public struct YouTubeItem: Decodable { + public let contentDetails: YouTubeItemContentDetails + public let id: String +} diff --git a/Sources/OrchardNestKit/YouTubeItemContentDetails.swift b/Sources/OrchardNestKit/YouTubeItemContentDetails.swift new file mode 100644 index 0000000..7dcfbc6 --- /dev/null +++ b/Sources/OrchardNestKit/YouTubeItemContentDetails.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct YouTubeItemContentDetails: Decodable { + public let duration: TimeInterval + + enum CodingKeys: String, CodingKey { + case duration + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let durationString = try container.decode(String.self, forKey: .duration) + duration = try TimeInterval(iso8601: durationString) + } +} diff --git a/Sources/OrchardNestKit/YouTubeResponse.swift b/Sources/OrchardNestKit/YouTubeResponse.swift new file mode 100644 index 0000000..96a691a --- /dev/null +++ b/Sources/OrchardNestKit/YouTubeResponse.swift @@ -0,0 +1,5 @@ +import Foundation + +public struct YouTubeResponse: Decodable { + public let items: [YouTubeItem] +} diff --git a/Sources/OrchardNestServer/Array.swift b/Sources/OrchardNestServer/Array.swift new file mode 100644 index 0000000..ed03d81 --- /dev/null +++ b/Sources/OrchardNestServer/Array.swift @@ -0,0 +1,17 @@ +extension Array where Element == [String] { + func crossReduce() -> [[String]] { + reduce([[String]]()) { (arrays, newPaths) -> [[String]] in + if arrays.count > 0 { + return arrays.flatMap { (array) -> [[String]] in + newPaths.map { (newPath) -> [String] in + var newArray = array + newArray.append(newPath) + return newArray + } + } + } else { + return newPaths.map { [$0] } + } + } + } +} diff --git a/Sources/OrchardNestServer/ChannelStatusType.swift b/Sources/OrchardNestServer/ChannelStatusType.swift new file mode 100644 index 0000000..5a17cec --- /dev/null +++ b/Sources/OrchardNestServer/ChannelStatusType.swift @@ -0,0 +1,3 @@ +enum ChannelStatusType: String, Codable, CaseIterable { + case ignore +} diff --git a/Sources/OrchardNestServer/Configurator.swift b/Sources/OrchardNestServer/Configurator.swift index 1623fbc..e97ea33 100644 --- a/Sources/OrchardNestServer/Configurator.swift +++ b/Sources/OrchardNestServer/Configurator.swift @@ -12,22 +12,6 @@ extension Date { } } -extension HTML: ResponseEncodable { - public func encodeResponse(for request: Request) -> EventLoopFuture { - var headers = HTTPHeaders() - headers.add(name: .contentType, value: "text/html") - return request.eventLoop.makeSucceededFuture(.init( - status: .ok, headers: headers, body: .init(string: render()) - )) - } -} - -struct OrganizedSite { - let languageCode: String - let categorySlug: String - let site: Site -} - // public final class Configurator: ConfiguratorProtocol { public static let shared: ConfiguratorProtocol = Configurator() @@ -35,30 +19,8 @@ public final class Configurator: ConfiguratorProtocol { // ///// Called before your application initializes. public func configure(_ app: Application) throws { - // Register providers first - // try services.register(FluentPostgreSQLProvider()) - // try services.register(AuthenticationProvider()) - - // services.register(DirectoryIndexMiddleware.self) - - // Register middleware - // var middlewares = MiddlewareConfig() // Create _empty_ middleware config - // middlewares.use(SessionsMiddleware.self) // Enables sessions. - // let rootPath = Environment.get("ROOT_PATH") ?? app.directory.publicDirectory - -// app.webSockets = WebSocketRepository() -// -// app.middleware.use(DirectoryIndexMiddleware(publicDirectory: rootPath)) - app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) -// // Configure Leaf -// app.views.use(.leaf) -// app.leaf.cache.isEnabled = app.environment.isRelease -// app.middleware.use(ErrorMiddleware.default(environment: app.environment)) - // middlewares.use(ErrorMiddleware.self) // Catches errors and converts to HTTP response - // services.register(middlewares) - // Configure a SQLite database let postgreSQLConfig: PostgresConfiguration @@ -86,25 +48,6 @@ public final class Configurator: ConfiguratorProtocol { app.queues.configuration.refreshInterval = .seconds(25) app.queues.use(.fluent()) -// app.databases.middleware.use(UserEmailerMiddleware(app: app)) -// -// app.migrations.add(CreateDevice()) -// app.migrations.add(CreateAppleUser()) -// app.migrations.add(CreateDeviceWorkout()) -// app.migrations.add(ActivateWorkout()) - // let wss = NIOWebSocketServer.default() - -// app.webSocket("api", "v1", "workouts", ":id", "listen") { req, websocket in -// guard let idData = try? Base32CrockfordEncoding.encoding.decode(base32Encoded: req.parameters.get("id")!) else { -// return -// } -// let workoutID = UUID(data: idData) -// -// _ = Workout.find(workoutID, on: req.db).unwrap(or: Abort(HTTPResponseStatus.notFound)).flatMapThrowing { workout in -// let workoutId = try workout.requireID() -// app.webSockets.save(websocket, withID: workoutId) -// } -// } app.queues.add(RefreshJob()) app.queues.schedule(RefreshJob()).daily().at(.midnight) @@ -126,21 +69,10 @@ public final class Configurator: ConfiguratorProtocol { let api = app.grouped("api", "v1") - let markdownDirectory = app.directory.viewsDirectory - let parser = MarkdownParser() - - let textPairs = FileManager.default.enumerator(atPath: markdownDirectory)?.compactMap { $0 as? String }.map { path in - URL(fileURLWithPath: app.directory.viewsDirectory + path) - }.compactMap { url in - (try? String(contentsOf: url)).map { (url.deletingPathExtension().lastPathComponent, $0) } - } - - let pages = textPairs.map(Dictionary.init(uniqueKeysWithValues:))?.mapValues( - parser.parse - ) - - try app.register(collection: HTMLController(views: pages)) + try app.register(collection: HTMLController(markdownDirectory: app.directory.viewsDirectory)) try api.grouped("entires").register(collection: EntryController()) + try api.grouped("channels").register(collection: ChannelController()) + try api.grouped("categories").register(collection: CategoryController()) app.post("jobs") { req in req.queue.dispatch( diff --git a/Sources/OrchardNestServer/Controllers/DB/Middleware/ChannelMiddleware.swift b/Sources/OrchardNestServer/Controllers/DB/Middleware/ChannelMiddleware.swift new file mode 100644 index 0000000..446c9fb --- /dev/null +++ b/Sources/OrchardNestServer/Controllers/DB/Middleware/ChannelMiddleware.swift @@ -0,0 +1,22 @@ +import Fluent + +struct ChannelMiddleware: ModelMiddleware { + typealias Model = Channel + + func trim(model: Channel) -> Channel { + model.title = model.title.trimmingCharacters(in: .whitespacesAndNewlines) + model.author = model.author.trimmingCharacters(in: .whitespacesAndNewlines) + model.subtitle = model.subtitle?.trimmingCharacters(in: .whitespacesAndNewlines) + model.twitterHandle = model.twitterHandle?.trimmingCharacters(in: .whitespacesAndNewlines) + + return model + } + + func create(model: Channel, on database: Database, next: AnyModelResponder) -> EventLoopFuture { + next.create(trim(model: model), on: database) + } + + func update(model: Channel, on database: Database, next: AnyModelResponder) -> EventLoopFuture { + next.update(trim(model: model), on: database) + } +} diff --git a/Sources/OrchardNestServer/Controllers/DB/Middleware/EntryMiddleware.swift b/Sources/OrchardNestServer/Controllers/DB/Middleware/EntryMiddleware.swift new file mode 100644 index 0000000..872badc --- /dev/null +++ b/Sources/OrchardNestServer/Controllers/DB/Middleware/EntryMiddleware.swift @@ -0,0 +1,21 @@ +import Fluent + +struct EntryMiddleware: ModelMiddleware { + typealias Model = Entry + + func trim(model: Entry) -> Entry { + model.title = model.title.trimmingCharacters(in: .whitespacesAndNewlines) + model.summary = model.summary.trimmingCharacters(in: .whitespacesAndNewlines) + model.content = model.content?.trimmingCharacters(in: .whitespacesAndNewlines) + + return model + } + + func create(model: Entry, on database: Database, next: AnyModelResponder) -> EventLoopFuture { + return next.create(trim(model: model), on: database) + } + + func update(model: Entry, on database: Database, next: AnyModelResponder) -> EventLoopFuture { + return next.update(trim(model: model), on: database) + } +} diff --git a/Sources/OrchardNestServer/Controllers/DB/Migration/PodcastEpisodeMigration.swift b/Sources/OrchardNestServer/Controllers/DB/Migration/PodcastEpisodeMigration.swift index ec4aa2e..6efc4cb 100644 --- a/Sources/OrchardNestServer/Controllers/DB/Migration/PodcastEpisodeMigration.swift +++ b/Sources/OrchardNestServer/Controllers/DB/Migration/PodcastEpisodeMigration.swift @@ -6,6 +6,7 @@ struct PodcastEpisodeMigration: Migration { database.schema(PodcastEpisode.schema) .field("entry_id", .uuid, .identifier(auto: false), .references(Entry.schema, .id)) .field("audio", .string, .required) + .field("seconds", .int, .required) .create() } diff --git a/Sources/OrchardNestServer/Controllers/DB/Migration/YouTubeVideoMigration.swift b/Sources/OrchardNestServer/Controllers/DB/Migration/YouTubeVideoMigration.swift index 1be46c0..1179a77 100644 --- a/Sources/OrchardNestServer/Controllers/DB/Migration/YouTubeVideoMigration.swift +++ b/Sources/OrchardNestServer/Controllers/DB/Migration/YouTubeVideoMigration.swift @@ -6,6 +6,7 @@ struct YouTubeVideoMigration: Migration { database.schema(YoutubeVideo.schema) .field("entry_id", .uuid, .identifier(auto: false), .references(Entry.schema, .id)) .field("youtube_id", .string, .required) + .field("seconds", .int, .required) .unique(on: "youtube_id") .create() } diff --git a/Sources/OrchardNestServer/Controllers/Routing/CategoryController.swift b/Sources/OrchardNestServer/Controllers/Routing/CategoryController.swift new file mode 100644 index 0000000..ad9deb8 --- /dev/null +++ b/Sources/OrchardNestServer/Controllers/Routing/CategoryController.swift @@ -0,0 +1,27 @@ +import Fluent +import OrchardNestKit +import Vapor + +struct CategoryController { + func entries(req: Request) -> EventLoopFuture> { + guard let category = req.parameters.get("category") else { + return req.eventLoop.makeFailedFuture(Abort(.notFound)) + } + + return EntryController.entries(from: req.db) + .filter(Channel.self, \Channel.$category.$id == category) + .filter(Channel.self, \Channel.$language.$id == "en") + .paginate(for: req) + .flatMapThrowing { (page: Page) -> Page in + try page.map { (entry: Entry) -> EntryItem in + try EntryItem(entry: entry) + } + } + } +} + +extension CategoryController: RouteCollection { + func boot(routes: RoutesBuilder) throws { + routes.get(":category", "entries", use: entries) + } +} diff --git a/Sources/OrchardNestServer/Controllers/Routing/ChannelController.swift b/Sources/OrchardNestServer/Controllers/Routing/ChannelController.swift new file mode 100644 index 0000000..ece00a0 --- /dev/null +++ b/Sources/OrchardNestServer/Controllers/Routing/ChannelController.swift @@ -0,0 +1,26 @@ +import Fluent +import OrchardNestKit +import Vapor + +struct ChannelController { + func entries(req: Request) -> EventLoopFuture> { + guard let channel = req.parameters.get("channel").flatMap({ $0.base32UUID }) else { + return req.eventLoop.makeFailedFuture(Abort(.notFound)) + } + + return EntryController.entries(from: req.db) + .filter(Channel.self, \Channel.$id == channel) + .paginate(for: req) + .flatMapThrowing { (page: Page) -> Page in + try page.map { (entry: Entry) -> EntryItem in + try EntryItem(entry: entry) + } + } + } +} + +extension ChannelController: RouteCollection { + func boot(routes: RoutesBuilder) throws { + routes.get(":channel", "entries", use: entries) + } +} diff --git a/Sources/OrchardNestServer/Controllers/Routing/EntryController.swift b/Sources/OrchardNestServer/Controllers/Routing/EntryController.swift index a1e6a14..eb19f1c 100644 --- a/Sources/OrchardNestServer/Controllers/Routing/EntryController.swift +++ b/Sources/OrchardNestServer/Controllers/Routing/EntryController.swift @@ -2,68 +2,34 @@ import Fluent import OrchardNestKit import Vapor -struct InvalidURLFormat: Error {} - -extension String { - func asURL() throws -> URL { - guard let url = URL(string: self) else { - throw InvalidURLFormat() - } - return url - } -} - -extension Entry { - func category() throws -> EntryCategory { - guard let category = EntryCategoryType(rawValue: channel.$category.id) else { - return .development - } - - if let url = podcastEpisode.flatMap({ URL(string: $0.audioURL) }) { - return .podcasts(url) - } else if let youtubeID = youtubeVideo?.youtubeId { - return .youtube(youtubeID) - } else { - return try EntryCategory(type: category) +struct EntryController { + static func entries(from database: Database) -> QueryBuilder { + return Entry.query(on: database).with(\.$channel) { builder in + builder.with(\.$podcasts).with(\.$youtubeChannels) } + .join(parent: \.$channel) + .with(\.$podcastEpisodes) + .join(children: \.$podcastEpisodes, method: .left) + .with(\.$youtubeVideos) + .join(children: \.$youtubeVideos, method: .left) + .sort(\.$publishedAt, .descending) } -} - -extension EntryChannel { - init(channel: Channel) throws { - try self.init( - id: channel.requireID(), - title: channel.title, - siteURL: channel.siteUrl.asURL(), - author: channel.author, - twitterHandle: channel.twitterHandle, - imageURL: channel.imageURL?.asURL(), - podcastAppleId: channel.$podcasts.value?.first?.appleId - ) - } -} -extension EntryItem { - init(entry: Entry) throws { - try self.init( - id: entry.requireID(), - channel: EntryChannel(channel: entry.channel), - category: entry.category(), - feedId: entry.feedId, - title: entry.title, - summary: entry.summary, - url: entry.url.asURL(), - imageURL: entry.imageURL?.asURL(), - publishedAt: entry.publishedAt - ) + func list(req: Request) -> EventLoopFuture> { + return Self.entries(from: req.db) + .paginate(for: req) + .flatMapThrowing { (page: Page) -> Page in + try page.map { (entry: Entry) -> EntryItem in + try EntryItem(entry: entry) + } + } } -} -struct EntryController { - func list(req: Request) -> EventLoopFuture> { - return Entry.query(on: req.db) - .sort(\.$publishedAt, .descending) - .with(\.$channel) + func latest(req: Request) -> EventLoopFuture> { + return Self.entries(from: req.db) + .join(LatestEntry.self, on: \Entry.$id == \LatestEntry.$id) + .filter(Channel.self, \Channel.$category.$id != "updates") + .filter(Channel.self, \Channel.$language.$id == "en") .paginate(for: req) .flatMapThrowing { (page: Page) -> Page in try page.map { (entry: Entry) -> EntryItem in @@ -76,5 +42,6 @@ struct EntryController { extension EntryController: RouteCollection { func boot(routes: RoutesBuilder) throws { routes.get("", use: list) + routes.get("latest", use: latest) } } diff --git a/Sources/OrchardNestServer/Controllers/Routing/HTMLController.swift b/Sources/OrchardNestServer/Controllers/Routing/HTMLController.swift index 7319217..86a555b 100644 --- a/Sources/OrchardNestServer/Controllers/Routing/HTMLController.swift +++ b/Sources/OrchardNestServer/Controllers/Routing/HTMLController.swift @@ -3,306 +3,9 @@ import FluentSQL import Ink import OrchardNestKit import Plot +import QueuesFluentDriver import Vapor -struct InvalidDatabaseError: Error {} - -extension Node where Context == HTML.BodyContext { - static func playerForPodcast(withAppleId appleId: Int) -> Self { - .ul( - .class("podcast-players"), - .li( - .a( - .href("https://podcasts.apple.com/podcast/id\(appleId)"), - .img( - .src("/images/podcast-players/apple/icon.svg") - ), - .div( - .div( - .text("Listen on") - ), - .div( - .class("name"), - .text("Apple Podcasts") - ) - ) - ) - ), - .li( - .a( - .href("https://overcast.fm/itunes\(appleId)"), - .img( - .src("/images/podcast-players/overcast/icon.svg") - ), - .div( - .div( - .text("Listen on") - ), - .div( - .class("name"), - .text("Overcast") - ) - ) - ) - ), - .li( - .a( - .href("https://castro.fm/itunes/\(appleId)"), - .img( - .src("/images/podcast-players/castro/icon.svg") - ), - .div( - .div( - .text("Listen on") - ), - .div( - .class("name"), - .text("Castro") - ) - ) - ) - ), - .li( - .a( - .href("https://podcasts.apple.com/podcast/id\(appleId)"), - .img( - .src("/images/podcast-players/pocketcasts/icon.svg") - ), - .div( - .div( - .text("Listen on") - ), - .div( - .class("name"), - .text("Pocket Casts") - ) - ) - ) - ) - ) - } -} - -extension Node where Context == HTML.BodyContext { - static func filters() -> Self { - .nav( - .class("posts-filter clearfix row"), - .ul( - .class("column"), - .li(.a(.class("button"), .href("/"), .i(.class("el el-calendar")), .text(" Latest"))), - .li(.a(.class("button"), .href("/category/development"), .i(.class("el el-cogs")), .text(" Development"))), - .li(.a(.class("button"), .href("/category/marketing"), .i(.class("el el-bullhorn")), .text(" Marketing"))), - .li(.a(.class("button"), .href("/category/design"), .i(.class("el el-brush")), .text(" Design"))), - .li(.a(.class("button"), .href("/category/podcasts"), .i(.class("el el-podcast")), .text(" Podcasts"))), - .li(.a(.class("button"), .href("/category/youtube"), .i(.class("el el-video")), .text(" YouTube"))), - .li(.a(.class("button"), .href("/category/newsletters"), .i(.class("el el-envelope")), .text(" Newsletters"))) - ) - ) - } -} - -extension Node where Context == HTML.BodyContext { - static func header() -> Self { - .header( - .class("container"), - .nav( - .class("row"), - .ul( - .class("column"), - .li(.a(.href("/"), .i(.class("el el-home")), .text(" Home"))), - .li(.a(.href("/about"), .i(.class("el el-info-circle")), .text(" About"))), - .li(.a(.href("/support"), .i(.class("el el-question-sign")), .text(" Support"))) - ), - .ul(.class("float-right column"), - .li(.a(.href("https://github.com/brightdigit/OrchardNest"), .i(.class("el el-github")), .text(" GitHub"))), - .li(.a(.href("https://twitter.com/OrchardNest"), .i(.class("el el-twitter")), .text(" Twitter")))) - ), - .div( - .class("row"), - .h1( - .class("column"), - .img( - .class("logo"), - .src("/images/logo.svg") - ), - .text(" OrchardNest") - ) - ), - div( - .class("row"), - .p( - .class("tagline column"), - .text("Swift Articles and News") - ) - ) - ) - } -} - -extension Node where Context == HTML.DocumentContext { - static func head(withSubtitle subtitle: String, andDescription description: String) -> Self { - return - .head( - .title("OrchardNest - \(subtitle)"), - .meta(.charset(.utf8)), - .meta(.name("viewport"), .content("width=device-width, initial-scale=1")), - .meta(.name("description"), .content(description)), - .raw(""" - - - - """), - .link(.rel(.preload), .href("https://fonts.googleapis.com/css2?family=Catamaran:wght@100;400;800&display=swap"), .attribute(named: "as", value: "style")), - .link(.rel(.preload), .href("/styles/elusive-icons/css/elusive-icons.min.css"), .attribute(named: "as", value: "style")), - .link(.rel(.appleTouchIcon), .sizes("180x180"), .href("/apple-touch-icon.png")), - .link(.rel(.appleTouchIcon), .type("image/png"), .sizes("32x32"), .href("/favicon-32x32.png")), - .link(.rel(.appleTouchIcon), .type("image/png"), .sizes("16x16"), .href("/favicon-16x16.png")), - .link(.rel(.manifest), .href("/site.webmanifest")), - .link(.rel(.maskIcon), .href("/safari-pinned-tab.svg"), .color("#5bbad5")), - .meta(.name("msapplication-TileColor"), .content("#2b5797")), - .meta(.name("theme-color"), .content("#ffffff")), - .link(.rel(.stylesheet), .href("/styles/elusive-icons/css/elusive-icons.min.css")), - .link(.rel(.stylesheet), .href("/styles/normalize.css")), - .link(.rel(.stylesheet), .href("/styles/milligram.css")), - .link(.rel(.stylesheet), .href("/styles/style.css")), - .link(.rel(.stylesheet), .href("https://fonts.googleapis.com/css2?family=Catamaran:wght@100;400;800&display=swap")) - ) - } -} - -extension Node where Context == HTML.ListContext { - static func li(forEntryItem item: EntryItem, formatDateWith formatter: DateFormatter) -> Self { - return - .li( - .class("blog-post"), - - .a( - .href(item.url), - .class("title"), - .h3( - .i(.class("el el-\(item.category.elClass)")), - - .text(item.title) - ) - ), - .div( - .class("publishedAt"), - .text(formatter.string(from: item.publishedAt)) - ), - .unwrap(item.youtubeID) { - .div( - .class("video-content"), - .a( - .href(item.url), - .img(.src("https://img.youtube.com/vi/\($0)/mqdefault.jpg")) - ), - .iframe( - .attribute(named: "data-src", value: "https://www.youtube.com/embed/" + $0), - .allow("accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"), - .allowfullscreen(true) - ) - ) - - }, - .div( - .class("summary"), - .text(item.summary.plainTextShort) - ), - .unwrap(item.podcastEpisodeURL) { - .audio( - .controls(true), - .attribute(named: "preload", value: "none"), - .source( - .src($0) - ) - ) - }, - .unwrap(item.channel.podcastAppleId) { - .playerForPodcast(withAppleId: $0) - }, - .div( - .class("author"), - .text("By "), - .text(item.channel.author), - .text(" at "), - .a( - .href("/channels/" + item.channel.id.uuidString), - .text(item.channel.siteURL.host ?? item.channel.title) - ), - .unwrap(item.channel.twitterHandle) { - .a( - .href("https://twitter.com/\($0)"), - .class("button twitter-handle"), - .i(.class("el el-twitter")), - .text(" @\($0)") - ) - } - ), - .div( - .class("social-share clearfix"), - .text("Share"), - .ul( - .li( - .a( - .class("button"), - .href(item.twitterShareLink), - .i(.class("el el-twitter")), - .text(" Tweet") - ) - ) - ) - ) - ) - } -} - -extension String { - var plainTextShort: String { - var result: String - - result = trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil) - guard result.count > 240 else { - return result - } - return result.prefix(240).components(separatedBy: " ").dropLast().joined(separator: " ").appending("...") - } -} - -extension EntryCategoryType { - var elClass: String { - switch self { - case .companies: - return "website" - case .design: - return "brush" - case .development: - return "cogs" - case .marketing: - return "bullhorn" - case .newsletters: - return "envelope" - case .podcasts: - return "podcast" - case .updates: - return "file-new" - case .youtube: - return "video" - } - } -} - -extension EntryCategory { - var elClass: String { - return type.elClass - } -} - struct HTMLController { let views: [String: Markdown] static let dateFormatter: DateFormatter = { @@ -312,8 +15,18 @@ struct HTMLController { return formatter }() - init(views: [String: Markdown]?) { - self.views = views ?? [String: Markdown]() + init(markdownDirectory: String) { + let parser = MarkdownParser() + + let textPairs = FileManager.default.enumerator(atPath: markdownDirectory)?.compactMap { $0 as? String }.map { path in + URL(fileURLWithPath: markdownDirectory + path) + }.compactMap { url in + (try? String(contentsOf: url)).map { (url.deletingPathExtension().lastPathComponent, $0) } + } + + views = textPairs.map(Dictionary.init(uniqueKeysWithValues:))?.mapValues( + parser.parse + ) ?? [String: Markdown]() } func category(req: Request) throws -> EventLoopFuture { @@ -321,18 +34,9 @@ struct HTMLController { throw Abort(.notFound) } - return Entry.query(on: req.db) - .with(\.$channel) { builder in - builder.with(\.$podcasts).with(\.$youtubeChannels) - } - .join(parent: \.$channel) - .with(\.$podcastEpisodes) - .join(children: \.$podcastEpisodes, method: .left) - .with(\.$youtubeVideos) - .join(children: \.$youtubeVideos, method: .left) + return EntryController.entries(from: req.db) .filter(Channel.self, \Channel.$category.$id == category) .filter(Channel.self, \Channel.$language.$id == "en") - .sort(\.$publishedAt, .descending) .limit(32) .all() .flatMapThrowing { (entries) -> [Entry] in @@ -348,6 +52,7 @@ struct HTMLController { HTML( .head(withSubtitle: "Swift Articles and News", andDescription: "Swift Articles and News of Category \(category)"), .body( + .class("category \(category)"), .header(), .main( .class("container"), @@ -395,21 +100,12 @@ struct HTMLController { } func channel(req: Request) throws -> EventLoopFuture { - guard let channel = req.parameters.get("channel").flatMap(UUID.init(uuidString:)) else { + guard let channel = req.parameters.get("channel").flatMap({ $0.base32UUID }) else { throw Abort(.notFound) } - return Entry.query(on: req.db) - .with(\.$channel) { builder in - builder.with(\.$podcasts).with(\.$youtubeChannels) - } - .join(parent: \.$channel) - .with(\.$podcastEpisodes) - .join(children: \.$podcastEpisodes, method: .left) - .with(\.$youtubeVideos) - .join(children: \.$youtubeVideos, method: .left) + return EntryController.entries(from: req.db) .filter(Channel.self, \Channel.$id == channel) - .sort(\.$publishedAt, .descending) .limit(32) .all() .flatMapEachThrowing { @@ -439,18 +135,10 @@ struct HTMLController { } func index(req: Request) -> EventLoopFuture { - return Entry.query(on: req.db).join(LatestEntry.self, on: \Entry.$id == \LatestEntry.$id) - .with(\.$channel) { builder in - builder.with(\.$podcasts).with(\.$youtubeChannels) - } - .join(parent: \.$channel) - .with(\.$podcastEpisodes) - .join(children: \.$podcastEpisodes, method: .left) - .with(\.$youtubeVideos) - .join(children: \.$youtubeVideos, method: .left) + return EntryController.entries(from: req.db) + .join(LatestEntry.self, on: \Entry.$id == \LatestEntry.$id) .filter(Channel.self, \Channel.$category.$id != "updates") .filter(Channel.self, \Channel.$language.$id == "en") - .sort(\.$publishedAt, .descending) .limit(32) .all() .flatMapEachThrowing { @@ -478,13 +166,81 @@ struct HTMLController { ) } } + + func sitemap(req: Request) -> EventLoopFuture { + let last = (req.queue as? FluentQueue).map { + $0.list(state: .completed).map { $0.map { $0.queuedAt }.max() } + } ?? req.eventLoop.makeSucceededFuture(nil) + let urls = req.application.routes.all.filter { route in + + guard route.method == .GET else { + return false + } + + if case let .constant(name) = route.path.first { + guard name != "api" else { + return false + } + } + + if case let .constant(name) = route.path.last { + guard name != "sitemap.xml" else { + return false + } + } + + return true + }.map { (route) -> EventLoopFuture<[URL]> in + let baseURL = URL(string: "https://orchardnest.com")! + + let components: [SiteMapPathComponent] = route.path.compactMap { path in + switch path { + case let .constant(constant): + return .name(constant) + case let .parameter(parameter): + guard let mappable = MappableParameter(rawValue: parameter) else { + return nil + } + return .parameter(mappable) + default: + return nil + } + } + + let urls = components.map { (component) -> EventLoopFuture<[String]> in + switch component { + case let .name(name): + return req.eventLoop.makeSucceededFuture([name]) + case let .parameter(parameter): + return parameter.pathComponents(on: req.db, withViews: [String](self.views.keys), from: req.eventLoop) + } + }.flatten(on: req.eventLoop).map { $0.crossReduce().map { $0.joined(separator: "/") }.map(baseURL.safeAppendingPathComponent(_:)) } + + return urls + }.flatten(on: req.eventLoop) + + return urls.map { $0.flatMap { $0 }}.and(last).map { (urls, last) -> SiteMap in + SiteMap( + .forEach(urls) { url in + .url( + .loc(url), + .changefreq(.hourly), + .unwrap(last) { + .lastmod($0) + } + ) + } + ) + } + } } extension HTMLController: RouteCollection { func boot(routes: RoutesBuilder) throws { routes.get("", use: index) - routes.get("category", ":category", use: category) + routes.get("categories", ":category", use: category) routes.get(":page", use: page) routes.get("channels", ":channel", use: channel) + routes.get("sitemap.xml", use: sitemap) } } diff --git a/Sources/OrchardNestServer/EntryCategory.swift b/Sources/OrchardNestServer/EntryCategory.swift new file mode 100644 index 0000000..eb27115 --- /dev/null +++ b/Sources/OrchardNestServer/EntryCategory.swift @@ -0,0 +1,7 @@ +import OrchardNestKit + +public extension EntryCategory { + var elClass: String { + return type.elClass + } +} diff --git a/Sources/OrchardNestServer/EntryCategoryType.swift b/Sources/OrchardNestServer/EntryCategoryType.swift new file mode 100644 index 0000000..5e9552f --- /dev/null +++ b/Sources/OrchardNestServer/EntryCategoryType.swift @@ -0,0 +1,24 @@ +import OrchardNestKit + +public extension EntryCategoryType { + var elClass: String { + switch self { + case .companies: + return "website" + case .design: + return "brush" + case .development: + return "cogs" + case .marketing: + return "bullhorn" + case .newsletters: + return "envelope" + case .podcasts: + return "podcast" + case .updates: + return "file-new" + case .youtube: + return "video" + } + } +} diff --git a/Sources/OrchardNestServer/EntryChannel.swift b/Sources/OrchardNestServer/EntryChannel.swift new file mode 100644 index 0000000..2bbf609 --- /dev/null +++ b/Sources/OrchardNestServer/EntryChannel.swift @@ -0,0 +1,15 @@ +import OrchardNestKit + +public extension EntryChannel { + init(channel: Channel) throws { + try self.init( + id: channel.requireID(), + title: channel.title, + siteURL: channel.siteUrl.asURL(), + author: channel.author, + twitterHandle: channel.twitterHandle, + imageURL: channel.imageURL?.asURL(), + podcastAppleId: channel.$podcasts.value?.first?.appleId + ) + } +} diff --git a/Sources/OrchardNestServer/EntryItem.swift b/Sources/OrchardNestServer/EntryItem.swift new file mode 100644 index 0000000..1b44e91 --- /dev/null +++ b/Sources/OrchardNestServer/EntryItem.swift @@ -0,0 +1,17 @@ +import OrchardNestKit + +public extension EntryItem { + init(entry: Entry) throws { + try self.init( + id: entry.requireID(), + channel: EntryChannel(channel: entry.channel), + category: entry.category(), + feedId: entry.feedId, + title: entry.title, + summary: entry.summary, + url: entry.url.asURL(), + imageURL: entry.imageURL?.asURL(), + publishedAt: entry.publishedAt + ) + } +} diff --git a/Sources/OrchardNestServer/HTML.swift b/Sources/OrchardNestServer/HTML.swift new file mode 100644 index 0000000..ec1be7e --- /dev/null +++ b/Sources/OrchardNestServer/HTML.swift @@ -0,0 +1,294 @@ +import Foundation +import OrchardNestKit +import Plot +import Vapor + +extension HTML: ResponseEncodable { + public func encodeResponse(for request: Request) -> EventLoopFuture { + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "text/html") + return request.eventLoop.makeSucceededFuture(.init( + status: .ok, headers: headers, body: .init(string: render()) + )) + } +} + +public extension Node where Context == HTML.BodyContext { + static func playerForPodcast(withAppleId appleId: Int) -> Self { + .ul( + .class("podcast-players"), + .li( + .a( + .href("https://podcasts.apple.com/podcast/id\(appleId)"), + .img( + .src("/images/podcast-players/apple/icon.svg"), + .alt("Listen on Apple Podcasts") + ), + .div( + .div( + .text("Listen on") + ), + .div( + .class("name"), + .text("Apple Podcasts") + ) + ) + ) + ), + .li( + .a( + .href("https://overcast.fm/itunes\(appleId)"), + .img( + .src("/images/podcast-players/overcast/icon.svg"), + .alt("Listen on Overcast") + ), + .div( + .div( + .text("Listen on") + ), + .div( + .class("name"), + .text("Overcast") + ) + ) + ) + ), + .li( + .a( + .href("https://castro.fm/itunes/\(appleId)"), + .img( + .src("/images/podcast-players/castro/icon.svg"), + .alt("Listen on Castro") + ), + .div( + .div( + .text("Listen on") + ), + .div( + .class("name"), + .text("Castro") + ) + ) + ) + ), + .li( + .a( + .href("https://podcasts.apple.com/podcast/id\(appleId)"), + .img( + .src("/images/podcast-players/pocketcasts/icon.svg"), + .alt("Listen on Pocket Casts") + ), + .div( + .div( + .text("Listen on") + ), + .div( + .class("name"), + .text("Pocket Casts") + ) + ) + ) + ) + ) + } +} + +public extension Node where Context == HTML.BodyContext { + static func filters() -> Self { + .nav( + .class("posts-filter clearfix row"), + .ul( + .class("column"), + .li(.a(.class("button"), .href("/"), .i(.class("el el-calendar")), .text(" Latest"))), + .li(.a(.class("button"), .href("/categories/development"), .i(.class("el el-cogs")), .text(" Development"))), + .li(.a(.class("button"), .href("/categories/marketing"), .i(.class("el el-bullhorn")), .text(" Marketing"))), + .li(.a(.class("button"), .href("/categories/design"), .i(.class("el el-brush")), .text(" Design"))), + .li(.a(.class("button"), .href("/categories/podcasts"), .i(.class("el el-podcast")), .text(" Podcasts"))), + .li(.a(.class("button"), .href("/categories/youtube"), .i(.class("el el-video")), .text(" YouTube"))), + .li(.a(.class("button"), .href("/categories/newsletters"), .i(.class("el el-envelope")), .text(" Newsletters"))) + ) + ) + } +} + +public extension Node where Context == HTML.BodyContext { + static func header() -> Self { + .header( + .class("container"), + .nav( + .class("row"), + .ul( + .class("column"), + .li(.a(.href("/"), .i(.class("el el-home")), .text(" Home"))), + .li(.a(.href("/about"), .i(.class("el el-info-circle")), .text(" About"))), + .li(.a(.href("/support"), .i(.class("el el-question-sign")), .text(" Support"))) + ), + .ul(.class("float-right column"), + .li(.a(.href("https://github.com/brightdigit/OrchardNest"), .i(.class("el el-github")), .text(" GitHub"))), + .li(.a(.href("https://twitter.com/OrchardNest"), .i(.class("el el-twitter")), .text(" Twitter")))) + ), + .div( + .class("row"), + .h1( + .class("column"), + .img( + .class("logo"), + .src("/images/logo.svg"), + .alt("OrchardNest") + ), + .text(" OrchardNest") + ) + ), + div( + .class("row"), + .p( + .class("tagline column"), + .text("Swift Articles and News") + ) + ) + ) + } +} + +public extension Node where Context == HTML.DocumentContext { + static func head(withSubtitle subtitle: String, andDescription description: String) -> Self { + return + .head( + .title("OrchardNest - \(subtitle)"), + .meta(.charset(.utf8)), + .meta(.name("viewport"), .content("width=device-width, initial-scale=1")), + .meta(.name("description"), .content(description)), + .raw(""" + + + + """), + .link( + .rel(.preload), + .href("https://fonts.googleapis.com/css2?family=Catamaran:wght@100;400;800&display=swap"), + .attribute(named: "as", value: "style") + ), + .link(.rel(.preload), .href("/styles/elusive-icons/css/elusive-icons.min.css"), .attribute(named: "as", value: "style")), + .link(.rel(.appleTouchIcon), .sizes("180x180"), .href("/apple-touch-icon.png")), + .link(.rel(.appleTouchIcon), .type("image/png"), .sizes("32x32"), .href("/favicon-32x32.png")), + .link(.rel(.appleTouchIcon), .type("image/png"), .sizes("16x16"), .href("/favicon-16x16.png")), + .link(.rel(.manifest), .href("/site.webmanifest")), + .link(.rel(.maskIcon), .href("/safari-pinned-tab.svg"), .color("#5bbad5")), + .meta(.name("msapplication-TileColor"), .content("#2b5797")), + .meta(.name("theme-color"), .content("#ffffff")), + .link(.rel(.stylesheet), .href("/styles/elusive-icons/css/elusive-icons.min.css")), + .link(.rel(.stylesheet), .href("/styles/normalize.css")), + .link(.rel(.stylesheet), .href("/styles/milligram.css")), + .link(.rel(.stylesheet), .href("/styles/style.css")), + .link(.rel(.stylesheet), .href("https://fonts.googleapis.com/css2?family=Catamaran:wght@100;400;800&display=swap")) + ) + } +} + +public extension Node where Context == HTML.ListContext { + static func li(forEntryItem item: EntryItem, formatDateWith formatter: DateFormatter) -> Self { + return + .li( + .class("blog-post"), + + .a( + .href(item.url), + .class("title"), + .h3( + .i(.class("el el-\(item.category.elClass)")), + + .text(item.title) + ) + ), + .div( + .class("publishedAt"), + .text(formatter.string(from: item.publishedAt)) + ), + .unwrap(item.seconds) { + .div( + .class("length"), + .text($0.positionalTime) + ) + }, + .unwrap(item.youtubeID, { + .div( + .class("video-content"), + .a( + .href(item.url), + .img( + .src("https://img.youtube.com/vi/\($0)/mqdefault.jpg"), + .alt(item.title) + ) + ), + .iframe( + .attribute(named: "data-src", value: "https://www.youtube.com/embed/" + $0), + .allow("accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"), + .allowfullscreen(true) + ) + ) + + }, else: + .unwrap(item.fallbackImageURL) { + .div( + .class("featured-image"), + .style("background-image: url(\($0));"), + .attribute(named: "title", value: item.title) + ) + }), + .div( + .class("summary"), + .text(item.summary.plainTextShort) + ), + .unwrap(item.podcastEpisodeURL) { + .audio( + .controls(true), + .attribute(named: "preload", value: "none"), + .source( + .src($0) + ) + ) + }, + .unwrap(item.channel.podcastAppleId) { + .playerForPodcast(withAppleId: $0) + }, + .div( + .class("author"), + .text("By "), + .text(item.channel.author), + .text(" at "), + .a( + .href("/channels/" + item.channel.id.base32Encoded.lowercased()), + .text(item.channel.siteURL.host ?? item.channel.title) + ), + .unwrap(item.channel.twitterHandle) { + .a( + .href("https://twitter.com/\($0)"), + .class("button twitter-handle"), + .i(.class("el el-twitter")), + .text(" @\($0)") + ) + } + ), + .div( + .class("social-share clearfix"), + .text("Share"), + .ul( + .li( + .a( + .class("button"), + .href(item.twitterShareLink), + .i(.class("el el-twitter")), + .text(" Tweet") + ) + ) + ) + ) + ) + } +} diff --git a/Sources/OrchardNestServer/Int.swift b/Sources/OrchardNestServer/Int.swift new file mode 100644 index 0000000..4b6f290 --- /dev/null +++ b/Sources/OrchardNestServer/Int.swift @@ -0,0 +1,11 @@ +import Foundation + +extension Int { + var positionalTime: String { + let (hours, hoursMod) = quotientAndRemainder(dividingBy: 3600) + let (minutes, seconds) = hoursMod.quotientAndRemainder(dividingBy: 60) + return [hours, minutes, seconds].filter { $0 > 0 }.map { + String(format: "%02i", $0) + }.joined(separator: ":") + } +} diff --git a/Sources/OrchardNestServer/InvalidDatabaseError.swift b/Sources/OrchardNestServer/InvalidDatabaseError.swift new file mode 100644 index 0000000..d83a5e6 --- /dev/null +++ b/Sources/OrchardNestServer/InvalidDatabaseError.swift @@ -0,0 +1 @@ +struct InvalidDatabaseError: Error {} diff --git a/Sources/OrchardNestServer/InvalidURLFormat.swift b/Sources/OrchardNestServer/InvalidURLFormat.swift new file mode 100644 index 0000000..44f4133 --- /dev/null +++ b/Sources/OrchardNestServer/InvalidURLFormat.swift @@ -0,0 +1 @@ +struct InvalidURLFormat: Error {} diff --git a/Sources/OrchardNestServer/MappableParameter.swift b/Sources/OrchardNestServer/MappableParameter.swift new file mode 100644 index 0000000..53d5a5a --- /dev/null +++ b/Sources/OrchardNestServer/MappableParameter.swift @@ -0,0 +1,20 @@ +import Fluent + +enum MappableParameter: String { + case category + case channel + case page +} + +extension MappableParameter { + func pathComponents(on database: Database, withViews views: [String], from eventLoop: EventLoop) -> EventLoopFuture<[String]> { + switch self { + case .channel: + return Channel.query(on: database).field(\.$id).all().map { $0.compactMap { $0.id?.base32Encoded.lowercased() } } + case .category: + return Category.query(on: database).field(\.$id).all().map { $0.compactMap { $0.id }} + case .page: + return eventLoop.makeSucceededFuture(views) + } + } +} diff --git a/Sources/OrchardNestServer/Models/DB/Channel.swift b/Sources/OrchardNestServer/Models/DB/Channel.swift index 8af3112..26d3def 100644 --- a/Sources/OrchardNestServer/Models/DB/Channel.swift +++ b/Sources/OrchardNestServer/Models/DB/Channel.swift @@ -1,13 +1,13 @@ import Fluent import Vapor -final class Channel: Model { - static var schema = "channels" +public final class Channel: Model { + public static var schema = "channels" - init() {} + public init() {} @ID() - var id: UUID? + public var id: UUID? @Field(key: "title") var title: String @@ -58,9 +58,30 @@ final class Channel: Model { } extension Channel: Validatable { - static func validations(_ validations: inout Validations) { + public static func validations(_ validations: inout Validations) { validations.add("siteUrl", as: URL.self) validations.add("feedUrl", as: URL.self) validations.add("imageURL", as: URL.self) } } + +extension UUID { + var base32Encoded: String { + // swiftlint:disable:next force_cast + let bytes = Mirror(reflecting: uuid).children.map { $0.value as! UInt8 } + return Data(bytes).base32EncodedString() + } +} + +extension String { + var base32UUID: UUID? { + guard let data = Data(base32Encoded: self) else { + return nil + } + var bytes = [UInt8](repeating: 0, count: data.count) + _ = bytes.withUnsafeMutableBufferPointer { + data.copyBytes(to: $0) + } + return NSUUID(uuidBytes: bytes) as UUID + } +} diff --git a/Sources/OrchardNestServer/Models/DB/ChannelStatus.swift b/Sources/OrchardNestServer/Models/DB/ChannelStatus.swift index 9557ea0..3dc0aa0 100644 --- a/Sources/OrchardNestServer/Models/DB/ChannelStatus.swift +++ b/Sources/OrchardNestServer/Models/DB/ChannelStatus.swift @@ -1,10 +1,6 @@ import Fluent import Vapor -enum ChannelStatusType: String, Codable, CaseIterable { - case ignore -} - final class ChannelStatus: Model { static var schema = "channel_statuses" diff --git a/Sources/OrchardNestServer/Models/DB/Entry.swift b/Sources/OrchardNestServer/Models/DB/Entry.swift index 1468195..947fb73 100644 --- a/Sources/OrchardNestServer/Models/DB/Entry.swift +++ b/Sources/OrchardNestServer/Models/DB/Entry.swift @@ -1,13 +1,14 @@ import Fluent +import OrchardNestKit import Vapor -final class Entry: Model, Content { - static var schema = "entries" +public final class Entry: Model, Content { + public static var schema = "entries" - init() {} + public init() {} @ID() - var id: UUID? + public var id: UUID? @Parent(key: "channel_id") var channel: Channel @@ -45,20 +46,36 @@ final class Entry: Model, Content { var podcastEpisodes: [PodcastEpisode] var podcastEpisode: PodcastEpisode? { - return podcastEpisodes.first + return $podcastEpisodes.value?.first } @Children(for: \.$entry) var youtubeVideos: [YoutubeVideo] var youtubeVideo: YoutubeVideo? { - return youtubeVideos.first + return $youtubeVideos.value?.first } } extension Entry: Validatable { - static func validations(_ validations: inout Validations) { + public static func validations(_ validations: inout Validations) { validations.add("url", as: URL.self) validations.add("imageURL", as: URL.self) } } + +public extension Entry { + func category() throws -> EntryCategory { + guard let category = EntryCategoryType(rawValue: channel.$category.id) else { + return .development + } + + if let podcastEpisode = self.podcastEpisode, let url = URL(string: podcastEpisode.audioURL) { + return .podcasts(url, podcastEpisode.seconds) + } else if let youtubeVideo = self.youtubeVideo { + return .youtube(youtubeVideo.youtubeId, youtubeVideo.seconds) + } else { + return try EntryCategory(type: category) + } + } +} diff --git a/Sources/OrchardNestServer/Models/DB/PodcastChannel.swift b/Sources/OrchardNestServer/Models/DB/PodcastChannel.swift index 13369af..5cd3b54 100644 --- a/Sources/OrchardNestServer/Models/DB/PodcastChannel.swift +++ b/Sources/OrchardNestServer/Models/DB/PodcastChannel.swift @@ -35,7 +35,6 @@ extension PodcastChannel { }.all().flatMapEach(on: database.eventLoop) { channel in channel.delete(on: database) }.flatMap { _ in - // context.logger.info("saving yt channel \"\(newChannel.youtubeId)\"") newChannel.save(on: database) } } diff --git a/Sources/OrchardNestServer/Models/DB/PodcastEpisode.swift b/Sources/OrchardNestServer/Models/DB/PodcastEpisode.swift index 7ce4c8b..8f21bea 100644 --- a/Sources/OrchardNestServer/Models/DB/PodcastEpisode.swift +++ b/Sources/OrchardNestServer/Models/DB/PodcastEpisode.swift @@ -6,9 +6,10 @@ final class PodcastEpisode: Model { init() {} - init(entryId: UUID, audioURL: String) { + init(entryId: UUID, audioURL: String, seconds: Int) { id = entryId self.audioURL = audioURL + self.seconds = seconds } @ID(custom: "entry_id", generatedBy: .user) @@ -17,6 +18,9 @@ final class PodcastEpisode: Model { @Field(key: "audio") var audioURL: String + @Field(key: "seconds") + var seconds: Int + @Parent(key: "entry_id") var entry: Entry } diff --git a/Sources/OrchardNestServer/Models/DB/YouTubeVideo.swift b/Sources/OrchardNestServer/Models/DB/YouTubeVideo.swift index 4219de6..5f6c4a4 100644 --- a/Sources/OrchardNestServer/Models/DB/YouTubeVideo.swift +++ b/Sources/OrchardNestServer/Models/DB/YouTubeVideo.swift @@ -6,9 +6,10 @@ final class YoutubeVideo: Model { init() {} - init(entryId: UUID, youtubeId: String) { + init(entryId: UUID, youtubeId: String, seconds: Int) { id = entryId self.youtubeId = youtubeId + self.seconds = seconds } @ID(custom: "entry_id", generatedBy: .user) @@ -17,6 +18,9 @@ final class YoutubeVideo: Model { @Field(key: "youtube_id") var youtubeId: String + @Field(key: "seconds") + var seconds: Int + @Parent(key: "entry_id") var entry: Entry } diff --git a/Sources/OrchardNestServer/Models/FeedChannel.swift b/Sources/OrchardNestServer/Models/FeedChannel.swift index 2d6ab31..139e540 100644 --- a/Sources/OrchardNestServer/Models/FeedChannel.swift +++ b/Sources/OrchardNestServer/Models/FeedChannel.swift @@ -9,10 +9,6 @@ struct EmptyError: Error {} extension FeedChannel { static func parseSite(_ site: OrganizedSite, using client: Client, on eventLoop: EventLoop) -> EventLoopFuture> { - // let uri = URI(string: site.site.feed_url.absoluteString) - // let headers = HTTPHeaders([("Host", uri.host!), ("User-Agent", "OrchardNest-Robot"), ("Accept", "*/*")]) - - // let promise = eventLoop.makePromise(of: Result.self) return client.get(URI(string: site.site.feed_url.absoluteString)).map { (response) -> Data? in response.body.map { buffer in Data(buffer: buffer) @@ -35,36 +31,5 @@ extension FeedChannel { } return eventLoop.future(newResult) } -// URLSession.shared.dataTask(with: site.site.feed_url) { data, _, error in -// let result: Result -// if let error = error { -// result = .failure(error) -// } else if let data = data { -// result = .success(data) -// } else { -// promise.fail(EmptyError()) -// return -// } -// promise.succeed(result) -// }.resume() -// return promise.futureResult.flatMap { (result) -> EventLoopFuture> in -// -// let responseBody: Data -// do { -// responseBody = try result.get() -// } catch { -// return eventLoop.future(.failure(.download(site.site.feed_url, error))) -// } -// let channel: FeedChannel -// do { -// channel = try FeedChannel(language: site.languageCode, category: site.categorySlug, site: site.site, data: responseBody) -// } catch { -// return eventLoop.future(.failure(.parser(site.site.feed_url, error))) -// } -// guard channel.items.count > 0 || channel.itemCount == channel.items.count else { -// return eventLoop.future(.failure(.items(site.site.feed_url))) -// } -// return eventLoop.future(.success(channel)) -// } } } diff --git a/Sources/OrchardNestServer/Models/FeedItemEntry.swift b/Sources/OrchardNestServer/Models/FeedItemEntry.swift index 4884729..4cbfd13 100644 --- a/Sources/OrchardNestServer/Models/FeedItemEntry.swift +++ b/Sources/OrchardNestServer/Models/FeedItemEntry.swift @@ -23,7 +23,6 @@ struct FeedItemEntry { newEntry.summary = config.feedItem.summary newEntry.title = config.feedItem.title newEntry.url = config.feedItem.url.absoluteString - // context.logger.info("saving entry for \"\(config.feedItem.url)\"") return newEntry.save(on: database).transform(to: Self(entry: newEntry, feedItem: config.feedItem)) } } @@ -31,16 +30,9 @@ struct FeedItemEntry { extension FeedItemEntry { var podcastEpisode: PodcastEpisode? { - guard let id = entry.id, let audioURL = feedItem.audio else { + guard let id = entry.id, let audioURL = feedItem.audio, let duration = feedItem.duration else { return nil } - return PodcastEpisode(entryId: id, audioURL: audioURL.absoluteString) - } - - var youtubeVideo: YoutubeVideo? { - guard let id = entry.id, let youtubeId = feedItem.ytId else { - return nil - } - return YoutubeVideo(entryId: id, youtubeId: youtubeId) + return PodcastEpisode(entryId: id, audioURL: audioURL.absoluteString, seconds: Int(duration.rounded())) } } diff --git a/Sources/OrchardNestServer/OrganizedSite.swift b/Sources/OrchardNestServer/OrganizedSite.swift new file mode 100644 index 0000000..c1da183 --- /dev/null +++ b/Sources/OrchardNestServer/OrganizedSite.swift @@ -0,0 +1,7 @@ +import OrchardNestKit + +struct OrganizedSite { + let languageCode: String + let categorySlug: String + let site: Site +} diff --git a/Sources/OrchardNestServer/RefreshJob.swift b/Sources/OrchardNestServer/RefreshJob.swift index 3d5ffa4..d2a92f7 100644 --- a/Sources/OrchardNestServer/RefreshJob.swift +++ b/Sources/OrchardNestServer/RefreshJob.swift @@ -3,6 +3,27 @@ import NIO import OrchardNestKit import Queues import Vapor +extension Collection { + func chunked(by distance: Int) -> [[Element]] { + var result: [[Element]] = [] + var batch: [Element] = [] + + for element in self { + batch.append(element) + + if batch.count == distance { + result.append(batch) + batch = [] + } + } + + if !batch.isEmpty { + result.append(batch) + } + + return result + } +} struct ApplePodcastResult: Codable { let collectionId: Int @@ -20,12 +41,35 @@ struct RefreshJob: ScheduledJob, Job { ) } + static let youtubeAPIKey = Environment.get("YOUTUBE_API_KEY")! + static let url = URL(string: "https://raw.githubusercontent.com/daveverwer/iOSDevDirectory/master/blogs.json")! static let basePodcastQueryURLComponents = URLComponents(string: """ https://itunes.apple.com/search?media=podcast&attribute=titleTerm&limit=1&entity=podcast """)! + static let youtubeQueryURLComponents = URLComponents(string: """ + https://www.googleapis.com/youtube/v3/videos?part=contentDetails&fields=items%2Fid%2Citems%2FcontentDetails%2Fduration&key=\(Self.youtubeAPIKey) + """)! + + static func queryURL(forYouTubeWithIds ids: [String]) -> URI { + var components = Self.youtubeQueryURLComponents + guard var queryItems = components.queryItems else { + preconditionFailure() + } + queryItems.append(URLQueryItem(name: "id", value: ids.joined(separator: ","))) + components.queryItems = queryItems + return URI( + scheme: components.scheme, + host: components.host, + port: components.port, + path: components.path, + query: components.query, + fragment: components.fragment + ) + } + static func queryURL(forPodcastWithTitle title: String) -> URI { var components = Self.basePodcastQueryURLComponents guard var queryItems = components.queryItems else { @@ -63,7 +107,11 @@ struct RefreshJob: ScheduledJob, Job { try response.content.decode([LanguageContent].self, using: decoder) }.map(SiteCatalogMap.init) - let ignoringFeedURLs = ChannelStatus.query(on: database).filter(\.$status == ChannelStatusType.ignore).field(\.$id).all().map { $0.compactMap { $0.id.flatMap(URL.init(string:)) }} + let ignoringFeedURLs = ChannelStatus.query(on: database) + .filter(\.$status == ChannelStatusType.ignore) + .field(\.$id) + .all() + .map { $0.compactMap { $0.id.flatMap(URL.init(string:)) }} return blogsDownload.and(ignoringFeedURLs).flatMap { (siteCatalogMap, ignoringFeedURLs) -> EventLoopFuture in let languages = siteCatalogMap.languages @@ -119,19 +167,6 @@ struct RefreshJob: ScheduledJob, Job { } return finalResults -// return organizedSites.map { orgSite in -// FeedChannel.parseSite(orgSite, using: context.application.client, on: context.eventLoop) -// .map { result in -// result.flatMap { FeedConfiguration.from( -// categorySlug: orgSite.categorySlug, -// languageCode: orgSite.languageCode, -// channel: $0, -// langMap: langMap, -// catMap: catMap -// ) -// } -// } -// }.flatten(on: context.eventLoop) } let groupedResults = futureFeedResults.map { results -> ([FeedConfiguration], [FeedError]) in @@ -245,11 +280,38 @@ struct RefreshJob: ScheduledJob, Job { } // save videos to entries - let futYTVideos = futureEntries.mapEachCompact { (entry) -> YoutubeVideo? in - entry.youtubeVideo - }.flatMapEach(on: database.eventLoop) { newVideo in - YoutubeVideo.upsert(newVideo, on: database) - } + + let futYTVideos = futureEntries.flatMap { (entries) -> EventLoopFuture<[YoutubeVideo]> in + entries + .compactMap { $0.feedItem.ytId } + .chunked(by: 50) + .map(Self.queryURL(forYouTubeWithIds:)) + .map { client.get($0) } + .flatten(on: client.eventLoop) + .flatMapEachThrowing { response in + try response.content.decode(YouTubeResponse.self).items.map { + (key: $0.id, value: $0.contentDetails.duration) + } + }.map { (arrays: [[(String, TimeInterval)]]) -> [(String, TimeInterval)] in + arrays.flatMap { $0 } + }.map([String: TimeInterval].init(uniqueKeysWithValues:)).map { durations in + entries.compactMap { (entry) -> YoutubeVideo? in + guard let id = entry.entry.id else { + return nil + } + guard let youtubeId = entry.feedItem.ytId else { + return nil + } + guard let duration = durations[youtubeId] else { + return nil + } + return YoutubeVideo(entryId: id, youtubeId: youtubeId, seconds: Int(duration.rounded())) + } + } + }.recover { _ in [YoutubeVideo]() } + .flatMapEach(on: database.eventLoop) { newVideo in + YoutubeVideo.upsert(newVideo, on: database) + } // save podcastepisodes to entries diff --git a/Sources/OrchardNestServer/SiteMap.swift b/Sources/OrchardNestServer/SiteMap.swift new file mode 100644 index 0000000..d66e852 --- /dev/null +++ b/Sources/OrchardNestServer/SiteMap.swift @@ -0,0 +1,12 @@ +import Plot +import Vapor + +extension SiteMap: ResponseEncodable { + public func encodeResponse(for request: Request) -> EventLoopFuture { + var headers = HTTPHeaders() + headers.add(name: .contentType, value: "application/xml") + return request.eventLoop.makeSucceededFuture(.init( + status: .ok, headers: headers, body: .init(string: render()) + )) + } +} diff --git a/Sources/OrchardNestServer/SiteMapPathComponent.swift b/Sources/OrchardNestServer/SiteMapPathComponent.swift new file mode 100644 index 0000000..1f5db5a --- /dev/null +++ b/Sources/OrchardNestServer/SiteMapPathComponent.swift @@ -0,0 +1,4 @@ +enum SiteMapPathComponent { + case parameter(MappableParameter) + case name(String) +} diff --git a/Sources/OrchardNestServer/String.swift b/Sources/OrchardNestServer/String.swift new file mode 100644 index 0000000..36a08d3 --- /dev/null +++ b/Sources/OrchardNestServer/String.swift @@ -0,0 +1,22 @@ +import Foundation + +public extension String { + func asURL() throws -> URL { + guard let url = URL(string: self) else { + throw InvalidURLFormat() + } + return url + } +} + +public extension String { + var plainTextShort: String { + var result: String + + result = trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil) + guard result.count > 240 else { + return result + } + return result.prefix(240).components(separatedBy: " ").dropLast().joined(separator: " ").appending("...") + } +} diff --git a/Sources/OrchardNestServer/URL.swift b/Sources/OrchardNestServer/URL.swift new file mode 100644 index 0000000..178a5ff --- /dev/null +++ b/Sources/OrchardNestServer/URL.swift @@ -0,0 +1,15 @@ +import Foundation + +extension URL { + func safeAppendingPathComponent(_ pathComponent: String) -> URL { + #if os(Linux) + if pathComponent.isEmpty { + return self + } else { + return appendingPathComponent(pathComponent) + } + #else + return appendingPathComponent(pathComponent) + #endif + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9b53db8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,76 @@ +# Docker Compose file for Vapor +# +# Install Docker on your system to run and test +# your Vapor app in a production-like environment. +# +# Note: This file is intended for testing and does not +# implement best practices for a production deployment. +# +# Learn more: https://docs.docker.com/compose/reference/ +# +# Build images: docker-compose build +# Start app: docker-compose up app +# Start database: docker-compose up db +# Run migrations: docker-compose up migrate +# Stop all: docker-compose down (add -v to wipe db) +# +version: '3.7' + +volumes: + db_data: + +x-shared_environment: &shared_environment + LOG_LEVEL: ${LOG_LEVEL:-debug} + DATABASE_HOST: db + DATABASE_NAME: orchardnest + DATABASE_USERNAME: orchardnest + DATABASE_PASSWORD: orchardnest + DATABASE_URL: postgres://orchardnest:orchardnest@db/orchardnest + +services: + app: + image: docker-app:latest + build: + context: . + environment: + <<: *shared_environment + depends_on: + - db + ports: + - '8080:8080' + # user: '0' # uncomment to run as root for testing purposes even though Dockerfile defines 'vapor' user. + command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] + migrate: + image: docker-app:latest + build: + context: . + environment: + <<: *shared_environment + depends_on: + - db + command: ["migrate", "--yes"] + deploy: + replicas: 0 + revert: + image: docker-app:latest + build: + context: . + environment: + <<: *shared_environment + depends_on: + - db + command: ["migrate", "--revert", "--yes"] + deploy: + replicas: 0 + db: + image: postgres:12-alpine + volumes: + - db_data:/var/lib/postgresql/data/pgdata + environment: + PGDATA: /var/lib/postgresql/data/pgdata + POSTGRES_USER: orchardnest + POSTGRES_PASSWORD: orchardnest + POSTGRES_DB: orchardnest + DATABASE_URL: postgres://orchardnest:orchardnest@db/orchardnest + ports: + - '5432:5432'