mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
324
Yattee/Services/SubscriptionImportExport.swift
Normal file
324
Yattee/Services/SubscriptionImportExport.swift
Normal file
@@ -0,0 +1,324 @@
|
||||
//
|
||||
// SubscriptionImportExport.swift
|
||||
// Yattee
|
||||
//
|
||||
// Service for importing and exporting subscriptions in various formats.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Import/Export Errors
|
||||
|
||||
enum SubscriptionImportError: LocalizedError {
|
||||
case invalidData
|
||||
case emptyFile
|
||||
case noValidSubscriptions
|
||||
case parsingFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidData:
|
||||
return String(localized: "subscriptions.import.error.invalidData")
|
||||
case .emptyFile:
|
||||
return String(localized: "subscriptions.import.error.emptyFile")
|
||||
case .noValidSubscriptions:
|
||||
return String(localized: "subscriptions.import.error.noValidSubscriptions")
|
||||
case .parsingFailed(let details):
|
||||
return String(localized: "subscriptions.import.error.parsingFailed \(details)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export Format
|
||||
|
||||
enum SubscriptionExportFormat: String, CaseIterable, Identifiable {
|
||||
case json = "JSON"
|
||||
case opml = "OPML"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .json: return "json"
|
||||
case .opml: return "opml"
|
||||
}
|
||||
}
|
||||
|
||||
var mimeType: String {
|
||||
switch self {
|
||||
case .json: return "application/json"
|
||||
case .opml: return "text/x-opml"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Result
|
||||
|
||||
struct SubscriptionImportResult {
|
||||
let channels: [(channelID: String, name: String)]
|
||||
let format: String
|
||||
}
|
||||
|
||||
// MARK: - Service
|
||||
|
||||
enum SubscriptionImportExport {
|
||||
|
||||
// MARK: - Format Detection
|
||||
|
||||
static func detectFormat(_ data: Data) -> String? {
|
||||
guard let content = String(data: data, encoding: .utf8) else { return nil }
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if trimmed.hasPrefix("<?xml") || trimmed.hasPrefix("<opml") {
|
||||
return "opml"
|
||||
} else if trimmed.contains("Channel Id") || trimmed.contains("channel_id") {
|
||||
return "csv"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - YouTube CSV Import
|
||||
|
||||
/// Parses YouTube subscription export CSV format.
|
||||
/// Expected format: Channel Id,Channel Url,Channel Title
|
||||
static func parseYouTubeCSV(_ data: Data) throws -> [(channelID: String, name: String)] {
|
||||
guard let content = String(data: data, encoding: .utf8) else {
|
||||
throw SubscriptionImportError.invalidData
|
||||
}
|
||||
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
guard !lines.isEmpty else {
|
||||
throw SubscriptionImportError.emptyFile
|
||||
}
|
||||
|
||||
var results: [(channelID: String, name: String)] = []
|
||||
var startIndex = 0
|
||||
|
||||
// Check for header row
|
||||
if let firstLine = lines.first?.lowercased(),
|
||||
firstLine.contains("channel id") || firstLine.contains("channel_id") {
|
||||
startIndex = 1
|
||||
}
|
||||
|
||||
for i in startIndex..<lines.count {
|
||||
let line = lines[i]
|
||||
let columns = parseCSVLine(line)
|
||||
|
||||
// YouTube format: Channel Id, Channel Url, Channel Title
|
||||
guard columns.count >= 3 else { continue }
|
||||
|
||||
let channelID = columns[0].trimmingCharacters(in: .whitespaces)
|
||||
let name = columns[2].trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Validate channel ID format (should start with UC for YouTube)
|
||||
guard !channelID.isEmpty, !name.isEmpty else { continue }
|
||||
|
||||
results.append((channelID: channelID, name: name))
|
||||
}
|
||||
|
||||
guard !results.isEmpty else {
|
||||
throw SubscriptionImportError.noValidSubscriptions
|
||||
}
|
||||
|
||||
LoggingService.shared.logSubscriptions("Parsed \(results.count) subscriptions from YouTube CSV")
|
||||
return results
|
||||
}
|
||||
|
||||
/// Parses a CSV line handling quoted fields
|
||||
private static func parseCSVLine(_ line: String) -> [String] {
|
||||
var results: [String] = []
|
||||
var current = ""
|
||||
var inQuotes = false
|
||||
|
||||
for char in line {
|
||||
if char == "\"" {
|
||||
inQuotes.toggle()
|
||||
} else if char == "," && !inQuotes {
|
||||
results.append(current)
|
||||
current = ""
|
||||
} else {
|
||||
current.append(char)
|
||||
}
|
||||
}
|
||||
results.append(current)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// MARK: - OPML Import
|
||||
|
||||
/// Parses OPML subscription format.
|
||||
static func parseOPML(_ data: Data) throws -> [(channelID: String, name: String)] {
|
||||
let parser = OPMLParser()
|
||||
let results = try parser.parse(data)
|
||||
|
||||
guard !results.isEmpty else {
|
||||
throw SubscriptionImportError.noValidSubscriptions
|
||||
}
|
||||
|
||||
LoggingService.shared.logSubscriptions("Parsed \(results.count) subscriptions from OPML")
|
||||
return results
|
||||
}
|
||||
|
||||
// MARK: - Auto-detect and Parse
|
||||
|
||||
/// Attempts to parse the data by auto-detecting the format.
|
||||
static func parseAuto(_ data: Data) throws -> SubscriptionImportResult {
|
||||
guard let format = detectFormat(data) else {
|
||||
// Try CSV first, then OPML
|
||||
if let results = try? parseYouTubeCSV(data), !results.isEmpty {
|
||||
return SubscriptionImportResult(channels: results, format: "CSV")
|
||||
}
|
||||
if let results = try? parseOPML(data), !results.isEmpty {
|
||||
return SubscriptionImportResult(channels: results, format: "OPML")
|
||||
}
|
||||
throw SubscriptionImportError.invalidData
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "csv":
|
||||
return SubscriptionImportResult(channels: try parseYouTubeCSV(data), format: "CSV")
|
||||
case "opml":
|
||||
return SubscriptionImportResult(channels: try parseOPML(data), format: "OPML")
|
||||
default:
|
||||
throw SubscriptionImportError.invalidData
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON Export
|
||||
|
||||
/// Exports subscriptions to JSON format using SubscriptionExport structure.
|
||||
static func exportToJSON(_ subscriptions: [Subscription]) -> Data? {
|
||||
let exports = subscriptions.map { SubscriptionExport(from: $0) }
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
|
||||
do {
|
||||
return try encoder.encode(exports)
|
||||
} catch {
|
||||
LoggingService.shared.logSubscriptionsError("Failed to encode subscriptions to JSON", error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OPML Export
|
||||
|
||||
/// Exports subscriptions to OPML format compatible with RSS readers.
|
||||
static func exportToOPML(_ subscriptions: [Subscription]) -> Data? {
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
let dateCreated = dateFormatter.string(from: Date())
|
||||
|
||||
var xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<opml version="1.0">
|
||||
<head>
|
||||
<title>Yattee Subscriptions</title>
|
||||
<dateCreated>\(dateCreated)</dateCreated>
|
||||
</head>
|
||||
<body>
|
||||
<outline text="YouTube Subscriptions" title="YouTube Subscriptions">
|
||||
|
||||
"""
|
||||
|
||||
for subscription in subscriptions {
|
||||
let name = escapeXML(subscription.name)
|
||||
let feedURL = "https://www.youtube.com/feeds/videos.xml?channel_id=\(subscription.channelID)"
|
||||
xml += """
|
||||
<outline text="\(name)" title="\(name)" type="rss" xmlUrl="\(feedURL)"/>
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
xml += """
|
||||
</outline>
|
||||
</body>
|
||||
</opml>
|
||||
"""
|
||||
|
||||
return xml.data(using: .utf8)
|
||||
}
|
||||
|
||||
/// Escapes special XML characters
|
||||
private static func escapeXML(_ string: String) -> String {
|
||||
string
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
|
||||
/// Generates a filename for export
|
||||
static func generateExportFilename(format: SubscriptionExportFormat) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||
let dateString = dateFormatter.string(from: Date())
|
||||
return "yattee-subscriptions-\(dateString).\(format.fileExtension)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OPML Parser
|
||||
|
||||
private class OPMLParser: NSObject, XMLParserDelegate {
|
||||
private var results: [(channelID: String, name: String)] = []
|
||||
private var parseError: Error?
|
||||
|
||||
func parse(_ data: Data) throws -> [(channelID: String, name: String)] {
|
||||
let parser = XMLParser(data: data)
|
||||
parser.delegate = self
|
||||
parser.parse()
|
||||
|
||||
if let error = parseError {
|
||||
throw error
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String,
|
||||
namespaceURI: String?, qualifiedName qName: String?,
|
||||
attributes attributeDict: [String: String] = [:]) {
|
||||
guard elementName.lowercased() == "outline" else { return }
|
||||
|
||||
// Try to extract channel ID from xmlUrl
|
||||
if let xmlUrl = attributeDict["xmlUrl"] ?? attributeDict["xmlurl"],
|
||||
let channelID = extractChannelID(from: xmlUrl) {
|
||||
let name = attributeDict["text"] ?? attributeDict["title"] ?? "Unknown Channel"
|
||||
results.append((channelID: channelID, name: name))
|
||||
}
|
||||
}
|
||||
|
||||
private func extractChannelID(from urlString: String) -> String? {
|
||||
// Handle YouTube RSS feed URL: youtube.com/feeds/videos.xml?channel_id=UCXXX
|
||||
if urlString.contains("channel_id=") {
|
||||
if let range = urlString.range(of: "channel_id=") {
|
||||
let afterParam = urlString[range.upperBound...]
|
||||
let channelID = afterParam.prefix(while: { $0 != "&" && $0 != " " })
|
||||
if !channelID.isEmpty {
|
||||
return String(channelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle YouTube channel URL: youtube.com/channel/UCXXX
|
||||
if urlString.contains("/channel/") {
|
||||
if let range = urlString.range(of: "/channel/") {
|
||||
let afterChannel = urlString[range.upperBound...]
|
||||
let channelID = afterChannel.prefix(while: { $0 != "/" && $0 != "?" && $0 != " " })
|
||||
if !channelID.isEmpty {
|
||||
return String(channelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) {
|
||||
self.parseError = SubscriptionImportError.parsingFailed(parseError.localizedDescription)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user