Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions Extensions/Notification+Names.swift
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
//
import Foundation

extension Notification.Name {
static let attachmentsPrefetched = Foundation.Notification.Name("com.nextcloud.notes.ios.attachmentsPrefetched")
}
95 changes: 93 additions & 2 deletions Networking/NoteSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<NoteStruct>) 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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -591,6 +649,39 @@ class NoteSessionManager {
}
}

func createAttachment(noteId: Int,
fileData: Data,
filename: String,
mimeType: String,
completion: @escaping (Result<String, NoteError>) -> 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...")

Expand Down
21 changes: 19 additions & 2 deletions Networking/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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, _):
Expand Down Expand Up @@ -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):
Expand Down
84 changes: 84 additions & 0 deletions Shared/AttachmentHelper.swift
Original file line number Diff line number Diff line change
@@ -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*
(?<url>[^)\s]+) # URL: until closing paranthesis or first space
(?:\s+["'][^"']*["'])? # optional title in '...' or "..."
\s*
\)
"""#,
options: [.allowCommentsAndWhitespace]
)

// Parse HTML images: <img src = "path"> or <img src='path'>
let htmlImg = try! NSRegularExpression(
pattern: #"<img\b[^>]*\bsrc\s*=\s*(['"])(?<url>.*?)\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[..<colon].contains(where: { $0 == "/" }) == false {
return false
}
return true
}
logger.notice("Path candidates after filtering: \(filteredCandidates, privacy: .public)")

if removeUrlEncoding {
let normalized: [String] = filteredCandidates.map { raw in
let decoded = raw.removingPercentEncoding ?? raw
return decoded
}

return normalized
}

return filteredCandidates


}

}

4 changes: 4 additions & 0 deletions Shared/KeychainHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ struct KeychainHelper {
UserDefaults.standard.set(newValue, forKey: "notesApiVersion")
}
}

static func notesApiVersionisAtLeast(_ minVersion: String) -> Bool {
KeychainHelper.notesApiVersion.compare(minVersion, options: .numeric, range: nil, locale: nil) != .orderedAscending
}

static var notesVersion: String {
get {
Expand Down
55 changes: 55 additions & 0 deletions Source/CoreData/AttachmentStore.swift
Original file line number Diff line number Diff line change
@@ -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/<noteId>/<relativePath>.
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)
}
}
1 change: 1 addition & 0 deletions Source/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions Source/PreviewViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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.")
}

Expand Down
Loading