diff --git a/Extensions/Notification+Names.swift b/Extensions/Notification+Names.swift new file mode 100644 index 00000000..c6811c65 --- /dev/null +++ b/Extensions/Notification+Names.swift @@ -0,0 +1,27 @@ +// +// Notification+Names.swift +// Nextcloud +// +// Created by oli-ver on 16/08/2025. +// Copyright © 2025 oli-ver. All rights reserved. +// +// Author oli-ver +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +import Foundation + +extension Notification.Name { + static let attachmentsPrefetched = Foundation.Notification.Name("com.nextcloud.notes.ios.attachmentsPrefetched") +} diff --git a/Networking/NoteSessionManager.swift b/Networking/NoteSessionManager.swift index afba2ab0..cdb03c59 100644 --- a/Networking/NoteSessionManager.swift +++ b/Networking/NoteSessionManager.swift @@ -498,14 +498,56 @@ class NoteSessionManager { } let router = Router.getNote(id: Int(note.id), exclude: "", etag: note.etag) let validStatusCode = KeychainHelper.notesApiVersion == Router.defaultApiVersion ? 200..<300 : 200..<201 + let attachmentHelper = AttachmentHelper() + session .request(router) .validate(statusCode: validStatusCode) .validate(contentType: [Router.applicationJson]) - .responseDecodable(of: NoteStruct.self) { response in + .responseDecodable(of: NoteStruct.self, decoder: JSONDecoder()) { (response: AFDataResponse) in switch response.result { case let .success(note): CDNote.update(notes: [note]) + self.logger.debug("Checking server API version \(KeychainHelper.notesApiVersion, privacy: .public) for compatibility for attachment download") + guard + KeychainHelper.notesApiVersionisAtLeast("1.4") + else { + self.logger.warning("Server with API version \(KeychainHelper.notesApiVersion, privacy: .public) does not support attachment download (API version >= 1.4), not parsing and downloading attachments") + return + } + + self.logger.debug("Searching for attachments") + let paths: [String] = attachmentHelper.extractRelativeAttachmentPaths(from: note.content, removeUrlEncoding: true) + self.logger.debug("Found the paths: \(paths, privacy: .public)") + + guard !paths.isEmpty else { + self.logger.notice("Searching for attachments completed, no paths found in note") + completion?() + return + } + + let group = DispatchGroup() + for path in paths { + group.enter() + Task { [weak self] in + guard let self else { return } + do { + let data = try await self.getAttachment(noteId: Int(note.id), path: path) + self.logger.debug("Attachment for note ID \(note.id, privacy: .public) and path \(path, privacy: .public) downloaded successfully") + try AttachmentStore.shared.store(data: data, noteId: Int(note.id), path: path) + self.logger.notice("Attachment for note ID \(note.id, privacy: .public) and path \(path, privacy: .public) stored successfully") + } catch { + self.logger.error("Attachment download for note ID \(note.id, privacy: .public) and path \(path, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") + } + } + group.leave() + } + + group.notify(queue: .main) { + completion?() + } + return + case let .failure(error): if let urlResponse = response.response { switch urlResponse.statusCode { @@ -527,7 +569,23 @@ class NoteSessionManager { } } completion?() - } + } + } + + + + func getAttachment(noteId: Int, path: String) async throws -> Data { + logger.notice("Getting attachment for noteId: \(noteId, privacy: .public), path: \(path, privacy: .public)") + let router = Router.getAttachment(noteId: noteId, path: path) + + return try await session + .request(router) + .onURLRequestCreation { req in + self.logger.debug("URL: \(req.url?.absoluteString ?? "nil", privacy: .public)") + } + .validate(statusCode: 200..<300) + .serializingData() + .value } func update(note: NoteProtocol, completion: SyncCompletionBlock? = nil) { @@ -591,6 +649,39 @@ class NoteSessionManager { } } + func createAttachment(noteId: Int, + fileData: Data, + filename: String, + mimeType: String, + completion: @escaping (Result) -> Void) { + struct AttachmentResponse: Decodable { + let filename: String + } + + let router = Router.createAttachment(noteId: noteId) + + session + .upload(multipartFormData: { form in + form.append(fileData, + withName: "file", + fileName: filename, + mimeType: mimeType) + }, with: router) + .validate(statusCode: 200..<300) + .responseDecodable(of: AttachmentResponse.self) { response in + switch response.result { + case .success(let payload): + completion(.success(payload.filename)) + case .failure(let error): + let message = ErrorMessage( + title: NSLocalizedString("Error Creating Attachment", comment: ""), + body: error.localizedDescription + ) + completion(.failure(NoteError(message: message))) + } + } + } + func delete(note: NoteProtocol, completion: SyncCompletionBlock? = nil) { logger.notice("Deleting note...") diff --git a/Networking/Router.swift b/Networking/Router.swift index 89a02e78..e5f5c6f7 100644 --- a/Networking/Router.swift +++ b/Networking/Router.swift @@ -16,7 +16,9 @@ import Version enum Router: URLRequestConvertible { case allNotes(exclude: String) case getNote(id: Int, exclude: String, etag: String) + case getAttachment(noteId: Int, path: String) case createNote(parameters: Parameters) + case createAttachment(noteId: Int) case updateNote(id: Int, paramters: Parameters) case deleteNote(id: Int) case settings @@ -27,9 +29,9 @@ enum Router: URLRequestConvertible { var method: HTTPMethod { switch self { - case .allNotes, .getNote, .settings: + case .allNotes, .getAttachment, .getNote, .settings: return .get - case .createNote: + case .createNote, .createAttachment: return .post case .updateNote, .updateSettings: return .put @@ -44,6 +46,10 @@ enum Router: URLRequestConvertible { return "/notes" case .getNote(let id , _, _): return "/notes/\(id)" + case .getAttachment(let noteId, _): + return "/attachment/\(noteId)" + case .createAttachment(let noteId): + return "/attachment/\(noteId)" case .createNote: return "/notes" case .updateNote(let id, _): @@ -110,9 +116,20 @@ enum Router: URLRequestConvertible { urlRequest.headers.add(.ifNoneMatch(etag)) } + urlRequest = try URLEncoding.default.encode(urlRequest, with: parameters) + case .getAttachment(_, let attachmentPath): + // TODO: Find out why only explicit API version is supported + let baseURLString = "\(server)/index.php/apps/notes/api/v1.4" + let url = try baseURLString.asURL() + urlRequest.url = url.appendingPathComponent(self.path) + + let parameters = ["path": attachmentPath] as [String: Any] urlRequest = try URLEncoding.default.encode(urlRequest, with: parameters) case .createNote(let parameters): urlRequest = try URLEncoding.default.encode(urlRequest, with: parameters) + case .createAttachment: + // Multipart body will be added by AF.upload; nothing to encode here. + break case .updateNote(_, let parameters): urlRequest = try URLEncoding.default.encode(urlRequest, with: parameters) case .updateSettings(let notesPath, let fileSuffix): diff --git a/Shared/AttachmentHelper.swift b/Shared/AttachmentHelper.swift new file mode 100644 index 00000000..5a43453e --- /dev/null +++ b/Shared/AttachmentHelper.swift @@ -0,0 +1,84 @@ +// +// AttachmentHelper.swift +// iOCNotes +// +// Created by oli-ver on 16/08/2025. +// Copyright © 2025 Nextcloud GmbH. All rights reserved. +// + +import Foundation +import os + +class AttachmentHelper{ + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AttachmentHelper") + + public func extractRelativeAttachmentPaths(from markdown: String, removeUrlEncoding: Bool) -> [String] { + logger.notice("Parsing markdown: \(markdown, privacy: .sensitive)") + // Parse markdown attachments: ![alt](path "optional title") + let mdImage = try! NSRegularExpression( + pattern: #""" + !\[ + [^\]]* # alt text (everything until closing square bracket) + \] + \( + \s* + (?[^)\s]+) # URL: until closing paranthesis or first space + (?:\s+["'][^"']*["'])? # optional title in '...' or "..." + \s* + \) + """#, + options: [.allowCommentsAndWhitespace] + ) + + // Parse HTML images: or + let htmlImg = try! NSRegularExpression( + pattern: #"]*\bsrc\s*=\s*(['"])(?.*?)\1"#, + options: [.caseInsensitive] + ) + + func matches(_ regex: NSRegularExpression, in s: String) -> [String] { + let ns = s as NSString + return regex.matches(in: s, range: NSRange(location: 0, length: ns.length)).compactMap { + let r = $0.range(withName: "url") + guard r.location != NSNotFound else { return nil } + return ns.substring(with: r).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + // collect download candidates + let candidates = matches(mdImage, in: markdown) + matches(htmlImg, in: markdown) + logger.notice("Found the following path candidates: \(candidates, privacy: .public)") + + // only retain relative paths + + let filteredCandidates = candidates.filter { raw in + let urlString = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if urlString.isEmpty { return false } + if urlString.hasPrefix("/") { return false } + if urlString.lowercased().hasPrefix("http://") { return false } + if urlString.lowercased().hasPrefix("https://") { return false } + if urlString.lowercased().hasPrefix("data:") { return false } + // Remove schemas if any + if let colon = urlString.firstIndex(of: ":"), urlString[.. Bool { + KeychainHelper.notesApiVersion.compare(minVersion, options: .numeric, range: nil, locale: nil) != .orderedAscending + } static var notesVersion: String { get { diff --git a/Source/CoreData/AttachmentStore.swift b/Source/CoreData/AttachmentStore.swift new file mode 100644 index 00000000..573d8e6d --- /dev/null +++ b/Source/CoreData/AttachmentStore.swift @@ -0,0 +1,55 @@ +// +// AttachmentStore.swift +// iOCNotes +// +// Created by oli-ver on 15/08/25. +// Copyright © 2025 Nextcloud GmbH. All rights reserved. +// + +import Foundation +import os + +final class AttachmentStore { + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AttachmentStore") + static let shared = AttachmentStore() + private let root: URL + + private init(fileManager: FileManager = .default) { + let base = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + self.root = base.appendingPathComponent("NoteAttachments", isDirectory: true) + try? fileManager.createDirectory(at: root, withIntermediateDirectories: true) + } + + // Normalizes relative paths and stores them in .../Caches/NoteAttachments//. + func fileURL(noteId: Int, relativePath: String) -> URL { + let safe = relativePath + .replacingOccurrences(of: "://", with: "_") + .trimmingCharacters(in: .whitespacesAndNewlines) + let noteFolder = root.appendingPathComponent(String(noteId), isDirectory: true) + return noteFolder.appendingPathComponent(safe) + } + + func contains(noteId: Int, path: String) -> Bool { + FileManager.default.fileExists(atPath: fileURL(noteId: noteId, relativePath: path).path) + } + + @discardableResult + func store(data: Data, noteId: Int, path: String) throws -> URL { + let url = fileURL(noteId: noteId, relativePath: path) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + logger.debug("Stored \(path, privacy: .public) for noteId \(noteId, privacy: .public) with url \(url, privacy: .public)") + return url + } + + func removeAll(for noteId: Int) { + let dir = root.appendingPathComponent(String(noteId), isDirectory: true) + try? FileManager.default.removeItem(at: dir) + } + + func purgeAll() { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } +} diff --git a/Source/EditorViewController.swift b/Source/EditorViewController.swift index 467ec30c..80b22f03 100644 --- a/Source/EditorViewController.swift +++ b/Source/EditorViewController.swift @@ -209,6 +209,7 @@ class EditorViewController: UIViewController { override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "showPreview" { if let preview = segue.destination as? PreviewViewController, let note = note { + preview.noteId = note.id preview.content = noteView.text preview.noteTitle = note.title preview.noteDate = noteView.headerLabel.text diff --git a/Source/PreviewViewController.swift b/Source/PreviewViewController.swift index 5f179aac..11f5950e 100644 --- a/Source/PreviewViewController.swift +++ b/Source/PreviewViewController.swift @@ -10,10 +10,11 @@ import UIKit class PreviewViewController: UIViewController { + var noteId: Int64? var content: String? var noteTitle: String? var noteDate: String? - + override func viewDidLoad() { super.viewDidLoad() var previewContent = "" @@ -23,11 +24,11 @@ class PreviewViewController: UIViewController { if let noteDate = noteDate { previewContent.append("*\(noteDate)*\n\n") } - if let content = content { + if let content = content, let noteId = noteId { do { previewContent.append(content) - let previewWebView = try PreviewWebView(markdown: content) { + let previewWebView = try PreviewWebView(markdown: content, noteId: noteId) { print("Markdown was rendered.") } diff --git a/Source/PreviewWebView.swift b/Source/PreviewWebView.swift index ebae5453..76f67666 100644 --- a/Source/PreviewWebView.swift +++ b/Source/PreviewWebView.swift @@ -9,11 +9,14 @@ import cmark_gfm_swift import UIKit import WebKit +import UniformTypeIdentifiers +import os typealias LoadCompletion = () -> Void class PreviewWebView: WKWebView { + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PreviewWebView") let bundle: Bundle private lazy var baseURL: URL = { @@ -38,19 +41,20 @@ class PreviewWebView: WKWebView { .strikethrough // Strikethrough ] - public init(markdown: String, completion: LoadCompletion? = nil) throws { + public init(markdown: String, noteId: Int64, completion: LoadCompletion? = nil) throws { let bundleUrl = Bundle.main.url(forResource: "Preview", withExtension: "bundle")! self.bundle = Bundle(url: bundleUrl)! loadCompletion = completion - - super.init(frame: .zero, configuration: WKWebViewConfiguration()) + let configuration = WKWebViewConfiguration() + configuration.setURLSchemeHandler(AttachmentSchemeHandler(), forURLScheme: AttachmentURL.scheme) + super.init(frame: .zero, configuration: configuration) navigationDelegate = self do { - try loadHTML(markdown) + try loadHTML(markdown, noteId: noteId) } catch { - // + logger.error("Error when loading HTML: \(error, privacy: .public)") } } @@ -61,11 +65,13 @@ class PreviewWebView: WKWebView { } private extension PreviewWebView { - - func loadHTML(_ markdown: String) throws { - let htmlString = Node(markdown: markdown, options: options, extensions: extensions)?.html - - let pageHTMLString = try htmlFromTemplate(htmlString ?? markdown) + + func loadHTML(_ markdown: String, noteId: Int64) throws { + let htmlString = Node(markdown: markdown, options: options, extensions: extensions)?.html ?? markdown + let attachmentHelper: AttachmentHelper = AttachmentHelper() + let relPaths = attachmentHelper.extractRelativeAttachmentPaths(from: markdown, removeUrlEncoding: false) + let htmlRewritten = rewriteAttachmentURLs(in: htmlString, noteId: noteId, relativePaths: relPaths) + let pageHTMLString = try htmlFromTemplate(htmlRewritten) loadHTMLString(pageHTMLString, baseURL: baseURL) } @@ -74,6 +80,93 @@ private extension PreviewWebView { return template.replacingOccurrences(of: "PREVIEW_HTML", with: htmlString) } + enum AttachmentURL { + static let scheme = "notes-attach" + + static func make(noteId: Int64, relativePath: String) -> URL { + var comps = URLComponents() + comps.scheme = scheme + comps.host = String(noteId) + comps.path = "/" + relativePath + return comps.url! + } + } + + private func rewriteAttachmentURLs(in html: String, noteId: Int64, relativePaths: [String]) -> String { + var out = html + var disallowed = CharacterSet.urlPathAllowed + disallowed.remove(charactersIn: "()") + for rel in relativePaths { + let encoded = rel.addingPercentEncoding(withAllowedCharacters: disallowed) ?? rel + let target = AttachmentURL.make(noteId: noteId, relativePath: rel).absoluteString + logger.debug("Replacing paths \(rel, privacy: .public) and \(encoded, privacy: .public) with \(target, privacy: .public)") + out = out.replacingOccurrences(of: "src=\"\(rel)\"", with: "src=\"\(target)\"") + .replacingOccurrences(of: "src=\"\(encoded)\"", with: "src=\"\(target)\"") + .replacingOccurrences(of: "href=\"\(rel)\"", with: "href=\"\(target)\"") + .replacingOccurrences(of: "href=\"\(encoded)\"", with: "href=\"\(target)\"") + } + return out + } + + final class AttachmentSchemeHandler: NSObject, WKURLSchemeHandler { + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + DispatchQueue.main.async { [weak urlSchemeTask] in + guard let task = urlSchemeTask else { return } + guard let url = task.request.url, + url.scheme == AttachmentURL.scheme, + let noteId = Int(url.host ?? "") else { + task.didFailWithError(NSError(domain: "Attachment", code: 400, userInfo: nil)) + return + } + + // Extract path and normalize + let relativePath = String(url.path.dropFirst()) // drop leading "/" + let encodedPath = relativePath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? relativePath + let fileURL = AttachmentStore.shared.fileURL(noteId: noteId, relativePath: encodedPath) + + guard FileManager.default.fileExists(atPath: fileURL.path) else { + task.didFailWithError(NSError(domain: "Attachment", code: 404, userInfo: [NSFilePathErrorKey: fileURL.path])) + return + } + + do { + let data = try Data(contentsOf: fileURL) + let mime = self.mimeType(for: fileURL) + let resp = URLResponse(url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: "utf-8") + task.didReceive(resp) + task.didReceive(data) + task.didFinish() + } catch { + task.didFailWithError(error) + } + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + // Do not send anything, the task is stopped + } + + private func mimeType(for fileURL: URL) -> String { + if #available(iOS 14.0, *) { + if let type = UTType(filenameExtension: fileURL.pathExtension), + let preferred = type.preferredMIMEType { + return preferred + } + } + // Fallbacks + switch fileURL.pathExtension.lowercased() { + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "webp": return "image/webp" + case "svg": return "image/svg+xml" + case "pdf": return "application/pdf" + default: return "application/octet-stream" + } + } + } + } extension PreviewWebView: WKNavigationDelegate { diff --git a/iOCNotes.xcodeproj/project.pbxproj b/iOCNotes.xcodeproj/project.pbxproj index 41b985b4..f638cd9a 100644 --- a/iOCNotes.xcodeproj/project.pbxproj +++ b/iOCNotes.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + A772AB7D2E5AAC790049636E /* AttachmentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A772AB7C2E5AAC790049636E /* AttachmentHelper.swift */; }; + A7AE55FA2E4F4716006F079C /* AttachmentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7AE55F92E4F4716006F079C /* AttachmentStore.swift */; }; + A7AE55FC2E507AC0006F079C /* Notification+Names.swift in Resources */ = {isa = PBXBuildFile; fileRef = A7AE55FB2E507AC0006F079C /* Notification+Names.swift */; }; + A7AE55FD2E507E0E006F079C /* Notification+Names.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7AE55FB2E507AC0006F079C /* Notification+Names.swift */; }; AA182D002DDCD6520058C246 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = AA182CFF2DDCD6520058C246 /* CodeScanner */; }; AA3054382E12AF9B00D33159 /* StatusRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3054372E12AF9800D33159 /* StatusRouter.swift */; }; AA30543A2E12AFFD00D33159 /* OCSRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3054392E12AFFB00D33159 /* OCSRouter.swift */; }; @@ -111,6 +115,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A772AB7C2E5AAC790049636E /* AttachmentHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentHelper.swift; sourceTree = ""; }; + A7AE55F92E4F4716006F079C /* AttachmentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentStore.swift; sourceTree = ""; }; + A7AE55FB2E507AC0006F079C /* Notification+Names.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Notification+Names.swift"; sourceTree = ""; }; AA3054372E12AF9800D33159 /* StatusRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusRouter.swift; sourceTree = ""; }; AA3054392E12AFFB00D33159 /* OCSRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCSRouter.swift; sourceTree = ""; }; AA30543B2E12B06700D33159 /* HTTPHeader+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPHeader+Extensions.swift"; sourceTree = ""; }; @@ -136,7 +143,8 @@ D00CDFC2194E65D4007505E9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; D00CDFC5194E6835007505E9 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; D00CDFC6194E6835007505E9 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; - D01067DD220BCE3C0047E090 /* NoteSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteSessionManager.swift; sourceTree = ""; }; + D00CDFC7194E6835007505E9 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; + D01067DD220BCE3C0047E090 /* NoteSessionManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoteSessionManager.swift; sourceTree = ""; }; D01067E12213BB5E0047E090 /* NoteProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoteProtocol.swift; sourceTree = ""; }; D01067E32213BDF20047E090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D01067E52213C17D0047E090 /* NotesTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesTableViewController.swift; sourceTree = ""; }; @@ -295,6 +303,7 @@ BD92271A22CD3743004E2408 /* UtilityExtensions.swift */, BD653F87231C43FA00D59A88 /* Constants.swift */, D09E644E248ABAEB003FB4C9 /* Throttler.swift */, + A772AB7C2E5AAC790049636E /* AttachmentHelper.swift */, ); path = Shared; sourceTree = ""; @@ -362,8 +371,6 @@ D0CFE53C1888A7BD00165839 /* Source */ = { isa = PBXGroup; children = ( - F3F734562C6FABF4007C8C0B /* Theming */, - F3F734552C6FABEA007C8C0B /* Screens */, D01067E32213BDF20047E090 /* AppDelegate.swift */, D0BCFE6C2454BF40007C4CA3 /* Categories.storyboard */, D018034923B037B1001CA4FC /* CategoriesSceneDelegate.swift */, @@ -372,6 +379,7 @@ D0B6D4AC22F91DDA0037E073 /* CollapsibleTableViewHeaderView.swift */, D0B6D4AD22F91DDA0037E073 /* CollapsibleTableViewHeaderView.xib */, BDD015A0234CEF86000BA001 /* Colors.xcassets */, + F3F7344C2C6FA550007C8C0B /* CoreData */, D06882ED22BB146200CEBC1F /* EditorViewController.swift */, D09E430E1E2C46930010E4B3 /* Main_iPhone.storyboard */, D0545F22230901D30001D165 /* PBHOpenInActivity.swift */, @@ -380,10 +388,11 @@ D09538421D32A56A006BB78E /* PreviewViewController.swift */, D0E60F27277801F8009CF78F /* PreviewWebView.swift */, D004DB7E23948F3D0080A7D1 /* SceneDelegate.swift */, - AABC95562DFC717A0023790D /* UIApplication+topViewController.swift */, + F3F734552C6FABEA007C8C0B /* Screens */, D0225708189745C40038232A /* Settings.bundle */, - F3F7344C2C6FA550007C8C0B /* CoreData */, D0CFE53D1888A7BD00165839 /* Supporting Files */, + F3F734562C6FABF4007C8C0B /* Theming */, + AABC95562DFC717A0023790D /* UIApplication+topViewController.swift */, ); path = Source; sourceTree = ""; @@ -432,6 +441,7 @@ F3E3C8DA2C6B7D3B00A80504 /* Extensions */ = { isa = PBXGroup; children = ( + A7AE55FB2E507AC0006F079C /* Notification+Names.swift */, F3E3C8DB2C6B7DA600A80504 /* UIColor+Extension.swift */, ); path = Extensions; @@ -440,6 +450,7 @@ F3F7344C2C6FA550007C8C0B /* CoreData */ = { isa = PBXGroup; children = ( + A7AE55F92E4F4716006F079C /* AttachmentStore.swift */, F3F734472C6FA550007C8C0B /* CDNote+CoreDataClass.swift */, F3F734482C6FA550007C8C0B /* CDNote+CoreDataProperties.swift */, F3F7344A2C6FA550007C8C0B /* Notes.xcdatamodeld */, @@ -637,6 +648,7 @@ BDD015A1234CF551000BA001 /* Colors.xcassets in Resources */, F7F0812A299D0B53006A2041 /* NCViewerNextcloudText.storyboard in Resources */, D00CDFBF194E65B3007505E9 /* Localizable.strings in Resources */, + A7AE55FC2E507AC0006F079C /* Notification+Names.swift in Resources */, D004DB7723920E820080A7D1 /* Preview.bundle in Resources */, F7BB8B7229915A650010D2F7 /* Launch Screen.storyboard in Resources */, D0B6D4AF22F91DDA0037E073 /* CollapsibleTableViewHeaderView.xib in Resources */, @@ -686,6 +698,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A7AE55FD2E507E0E006F079C /* Notification+Names.swift in Sources */, AABC95572DFC717F0023790D /* UIApplication+topViewController.swift in Sources */, F3E3C8DC2C6B7DA600A80504 /* UIColor+Extension.swift in Sources */, F3E3C8D92C6B7C2200A80504 /* NCBrand.swift in Sources */, @@ -722,12 +735,14 @@ D0E60F59277FADD5009CF78F /* Style.swift in Sources */, D03493B722F2837500E1A9B0 /* NoteTableViewCell.swift in Sources */, F3F734522C6FA550007C8C0B /* NotesData.swift in Sources */, + A7AE55FA2E4F4716006F079C /* AttachmentStore.swift in Sources */, D01067E22213BB5E0047E090 /* NoteProtocol.swift in Sources */, D059F7DB1D40596D00C252F2 /* NoteExporter.swift in Sources */, BD86DD8522B9CDD500115E5D /* KeychainHelper.swift in Sources */, F3F7345B2C6FAD80007C8C0B /* BaseUIVIewController.swift in Sources */, AA3054382E12AF9B00D33159 /* StatusRouter.swift in Sources */, D08713ED1D18DA40001EAF82 /* HeaderTextView.swift in Sources */, + A772AB7D2E5AAC790049636E /* AttachmentHelper.swift in Sources */, D0E60F5A277FADD5009CF78F /* UniversalTypes.swift in Sources */, D0E60F28277801F8009CF78F /* PreviewWebView.swift in Sources */, D03493B922F3CC5700E1A9B0 /* CategoryTableViewController.swift in Sources */,