From 211f1a19ae55e0da162266f27cd3e20dd16e084f Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Fri, 26 Jun 2026 11:33:04 +0200 Subject: [PATCH] fix(#10237): Support Adobe Lock Files Signed-off-by: Iva Horn --- .../Item/Item+LockFile.swift | 11 +- .../Utilities/LocalFiles.swift | 133 ++++++++++- .../ItemCreateTests.swift | 223 ++++++++++++++++++ .../ItemDeleteTests.swift | 79 ++++++- .../LocalFilesTests.swift | 53 +++++ 5 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/LocalFilesTests.swift diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift index 4b41af0178826..72aedce2eb0e1 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift @@ -79,7 +79,7 @@ extension Item { logger.info("Item to create is a lock file. Will attempt to lock the associated file on the server.", [.name: itemTemplate.filename]) - guard let targetFileName = originalFileName(fromLockFileName: itemTemplate.filename, dbManager: dbManager) else { + guard let targetFileName = lockFileTargetName(forLockFileName: itemTemplate.filename, parentServerUrl: parentItemRemotePath, dbManager: dbManager) else { logger.error("Will not lock the target file because it could not be determined based on the lock file name.", [.name: itemTemplate.filename]) return (nil, NSFileProviderError(.excludedFromSync)) } @@ -238,13 +238,16 @@ extension Item { } func deleteLockFile(domain: NSFileProviderDomain? = nil, dbManager: FilesDatabaseManager) async -> Error? { + // Always drop the local lock metadata first so it is never orphaned, even when the + // server lacks the locking capability or the guarded document cannot be determined. + dbManager.deleteItemMetadata(ocId: metadata.ocId) + guard await Self.assertRequiredCapabilities(domain: domain, itemIdentifier: itemIdentifier, account: account, remoteInterface: remoteInterface, logger: logger) else { + logger.info("Server does not support locking; removed local lock metadata without contacting the server.", [.name: metadata.fileName]) return nil } - dbManager.deleteItemMetadata(ocId: metadata.ocId) - - guard let originalFileName = originalFileName(fromLockFileName: metadata.fileName, dbManager: dbManager) else { + guard let originalFileName = lockFileTargetName(forLockFileName: metadata.fileName, parentServerUrl: metadata.serverUrl, dbManager: dbManager) else { logger.error("Could not get original filename from lock file filename so will not unlock target file.", [.name: metadata.fileName]) return nil } diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/LocalFiles.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/LocalFiles.swift index 99904a78023fb..5571123e6ef88 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/LocalFiles.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/LocalFiles.swift @@ -5,8 +5,36 @@ import Foundation import OSLog +/// Lock file extensions created by Adobe applications, mapped to the document extension(s) +/// the lock file may guard. /// -/// Determine whether the given filename is a lock file as created by certain applications like Microsoft Office or LibreOffice. +/// Unlike Microsoft Office (`~$…`) and LibreOffice (`.~lock.…#`) lock files, Adobe lock file +/// names do not encode the guarded document's own extension — only its base name — so the +/// document has to be located among the lock file's siblings. These extensions are exclusively +/// used for transient lock files (no legitimate user document uses them), which makes matching +/// by extension safe from false positives. +/// +/// - `idlk`: InDesign documents (`indd`) and InCopy stories (`icml`). +/// - `prlock`: Premiere Pro projects (`prproj`). +let adobeLockFileDocumentExtensions: [String: [String]] = [ + "idlk": ["indd", "icml"], + "prlock": ["prproj"] +] + +/// +/// Determine whether the given filename is a lock file as created by Adobe applications like InDesign or Premiere Pro. +/// +/// - Parameters: +/// - filename: The filename to check. +/// +/// - Returns: `true` if the filename is an Adobe lock file, `false` otherwise. +/// +public func isAdobeLockFileName(_ filename: String) -> Bool { + adobeLockFileDocumentExtensions.keys.contains((filename as NSString).pathExtension.lowercased()) +} + +/// +/// Determine whether the given filename is a lock file as created by certain applications like Microsoft Office, LibreOffice or Adobe. /// /// - Parameters: /// - filename: The filename to check. @@ -17,7 +45,9 @@ public func isLockFileName(_ filename: String) -> Bool { // Microsoft Office lock files filename.hasPrefix("~$") || // LibreOffice lock files - (filename.hasPrefix(".~lock.") && filename.hasSuffix("#")) + (filename.hasPrefix(".~lock.") && filename.hasSuffix("#")) || + // Adobe lock files + isAdobeLockFileName(filename) } /// @@ -67,3 +97,102 @@ public func originalFileName(fromLockFileName lockFilename: String, dbManager: F return nil } + +/// +/// Extract the document base name embedded in an Adobe lock file name. +/// +/// - Example for InDesign / InCopy: `Test` is extracted from `~Test~0kjyv(.idlk`. +/// - Example for Premiere Pro: `Test` is extracted from `Test.prlock`. +/// +/// Adobe lock file names drop the guarded document's own extension, so only the base name can +/// be recovered here. The matching document is resolved separately via ``adobeLockFileTargetName(lockFilename:parentServerUrl:dbManager:)``. +/// +/// - Returns: The document base name, or `nil` if it cannot be determined. +/// +func adobeLockFileDocumentBaseName(_ lockFilename: String) -> String? { + let ext = (lockFilename as NSString).pathExtension.lowercased() + var stem = (lockFilename as NSString).deletingPathExtension + + switch ext { + case "idlk": + // InDesign / InCopy: `~{base name}~{random token}(.idlk`. + if stem.hasPrefix("~") { + stem.removeFirst() + } + + if stem.hasSuffix("(") { + stem.removeLast() + } + + // The random token is the part after the last `~`; the base name is everything before it. + if let lastTilde = stem.lastIndex(of: "~") { + stem = String(stem[.. String? { + let ext = (lockFilename as NSString).pathExtension.lowercased() + + guard let documentExtensions = adobeLockFileDocumentExtensions[ext], + let baseName = adobeLockFileDocumentBaseName(lockFilename) + else { + return nil + } + + // Prefer the first matching extension, e.g. `.indd` over `.icml` for `.idlk`. + for documentExtension in documentExtensions { + let candidate = baseName + "." + documentExtension + + if dbManager.itemMetadatas + .where({ $0.serverUrl.equals(parentServerUrl) }) + .where({ $0.fileName.equals(candidate) }) + .first != nil + { + return candidate + } + } + + return nil +} + +/// +/// Resolve the document guarded by a lock file, regardless of the application that created it. +/// +/// Office and LibreOffice lock file names fully encode the document name, so it is decoded +/// directly via ``originalFileName(fromLockFileName:dbManager:)``. Adobe lock file names only +/// encode the base name, so the document is resolved by matching a sibling file via +/// ``adobeLockFileTargetName(lockFilename:parentServerUrl:dbManager:)``. +/// +/// - Parameters: +/// - lockFilename: The lock file name. +/// - parentServerUrl: The server URL of the directory containing the lock file. +/// - dbManager: The database manager to use for looking up files. +/// +/// - Returns: The guarded document's file name, or `nil` if it cannot be determined. +/// +public func lockFileTargetName(forLockFileName lockFilename: String, parentServerUrl: String, dbManager: FilesDatabaseManager) -> String? { + if isAdobeLockFileName(lockFilename) { + return adobeLockFileTargetName(lockFilename: lockFilename, parentServerUrl: parentServerUrl, dbManager: dbManager) + } + + return originalFileName(fromLockFileName: lockFilename, dbManager: dbManager) +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift index 4ffaab8ce23f6..fbaeb2d8f5eca 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift @@ -943,6 +943,229 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { XCTAssertFalse(targetRemote.locked) } + /// An Adobe lock file name does not encode the guarded document's extension, so the document + /// is resolved by matching a sibling file. Once resolved it is locked on the server just like + /// for Office lock files, while the lock file itself stays local and is never uploaded. + func testCreateAdobeInDesignLockFileLocksDocument() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + let folderRemote = MockRemoteItem( + identifier: "folder", + versionIdentifier: "1", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + let targetFileName = "MyDoc.indd" + let targetRemote = MockRemoteItem( + identifier: "folder/\(targetFileName)", + versionIdentifier: "1", + name: targetFileName, + remotePath: folderRemote.remotePath + "/" + targetFileName, + data: Data("test data".utf8), + locked: false, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + folderRemote.children = [targetRemote] + folderRemote.parent = rootItem + rootItem.children = [folderRemote] + + var folderMetadata = SendableItemMetadata( + ocId: folderRemote.identifier, fileName: "folder", account: Self.account + ) + folderMetadata.directory = true + Self.dbManager.addItemMetadata(folderMetadata) + + var targetMetadata = SendableItemMetadata( + ocId: targetRemote.identifier, fileName: targetFileName, account: Self.account + ) + targetMetadata.serverUrl += "/folder" + Self.dbManager.addItemMetadata(targetMetadata) + + // InDesign lock file: `~{base name}~{random token}(.idlk`. + let lockFileName = "~MyDoc~0kjyv(.idlk" + var lockFileMetadata = SendableItemMetadata( + ocId: "lock-id", fileName: lockFileName, account: Self.account + ) + lockFileMetadata.serverUrl += "/folder" + + let lockItemTemplate = Item( + metadata: lockFileMetadata, + parentItemIdentifier: .init(folderMetadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (createdItem, error) = await Item.create( + basedOn: lockItemTemplate, + contents: nil, + account: Self.account, + remoteInterface: remoteInterface, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + XCTAssertNotNil(createdItem) + XCTAssertEqual(createdItem?.isUploaded, false) + XCTAssertEqual(createdItem?.isDownloaded, true) + XCTAssertNil(error) + + let lockMetadata = Self.dbManager.itemMetadata(ocId: lockFileMetadata.ocId) + XCTAssertNotNil(lockMetadata) + XCTAssertEqual(lockMetadata?.classFile, "lock") + XCTAssertEqual(lockMetadata?.isLockFileOfLocalOrigin, true) + + // The lock file itself is never uploaded to the server. + XCTAssertFalse(folderRemote.children.contains { $0.name == lockFileName }) + // The guarded document is locked on the server. + XCTAssertTrue(targetRemote.locked) + } + + /// Premiere Pro lock files are named `{base name}.prlock` and guard a `.prproj` project. + func testCreateAdobePremiereLockFileLocksDocument() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + let folderRemote = MockRemoteItem( + identifier: "folder", + versionIdentifier: "1", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + let targetFileName = "MyDoc.prproj" + let targetRemote = MockRemoteItem( + identifier: "folder/\(targetFileName)", + versionIdentifier: "1", + name: targetFileName, + remotePath: folderRemote.remotePath + "/" + targetFileName, + data: Data("test data".utf8), + locked: false, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + folderRemote.children = [targetRemote] + folderRemote.parent = rootItem + rootItem.children = [folderRemote] + + var folderMetadata = SendableItemMetadata( + ocId: folderRemote.identifier, fileName: "folder", account: Self.account + ) + folderMetadata.directory = true + Self.dbManager.addItemMetadata(folderMetadata) + + var targetMetadata = SendableItemMetadata( + ocId: targetRemote.identifier, fileName: targetFileName, account: Self.account + ) + targetMetadata.serverUrl += "/folder" + Self.dbManager.addItemMetadata(targetMetadata) + + let lockFileName = "MyDoc.prlock" + var lockFileMetadata = SendableItemMetadata( + ocId: "lock-id", fileName: lockFileName, account: Self.account + ) + lockFileMetadata.serverUrl += "/folder" + + let lockItemTemplate = Item( + metadata: lockFileMetadata, + parentItemIdentifier: .init(folderMetadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (createdItem, error) = await Item.create( + basedOn: lockItemTemplate, + contents: nil, + account: Self.account, + remoteInterface: remoteInterface, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + XCTAssertNotNil(createdItem) + XCTAssertEqual(createdItem?.isUploaded, false) + XCTAssertNil(error) + XCTAssertEqual(Self.dbManager.itemMetadata(ocId: lockFileMetadata.ocId)?.isLockFileOfLocalOrigin, true) + XCTAssertFalse(folderRemote.children.contains { $0.name == lockFileName }) + XCTAssertTrue(targetRemote.locked) + } + + /// When the guarded document cannot be found (e.g. a stale lock file, or the document is not + /// in the database), the Adobe lock file is excluded from sync, matching Office behaviour. + func testCreateAdobeLockFileWithoutDocumentIsExcluded() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + let folderRemote = MockRemoteItem( + identifier: "folder", + versionIdentifier: "1", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + folderRemote.parent = rootItem + rootItem.children = [folderRemote] + + var folderMetadata = SendableItemMetadata( + ocId: folderRemote.identifier, fileName: "folder", account: Self.account + ) + folderMetadata.directory = true + Self.dbManager.addItemMetadata(folderMetadata) + + // No `MyDoc.indd` sibling exists in the database. + let lockFileName = "~MyDoc~0kjyv(.idlk" + var lockFileMetadata = SendableItemMetadata( + ocId: "lock-id", fileName: lockFileName, account: Self.account + ) + lockFileMetadata.serverUrl += "/folder" + + let lockItemTemplate = Item( + metadata: lockFileMetadata, + parentItemIdentifier: .init(folderMetadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (createdItem, error) = await Item.create( + basedOn: lockItemTemplate, + contents: nil, + account: Self.account, + remoteInterface: remoteInterface, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + XCTAssertNil(createdItem) + let unwrappedError = try XCTUnwrap(error) as? NSFileProviderError + XCTAssertEqual(unwrappedError, NSFileProviderError(.excludedFromSync)) + XCTAssertNil(Self.dbManager.itemMetadata(ocId: lockFileMetadata.ocId)) + } + /// /// A new file created in a folder the user marked "Always keep downloaded" /// must inherit that flag so the Finder overlay decoration matches its diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift index ec6b6b206a928..d06f4413cfe34 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift @@ -263,7 +263,7 @@ final class ItemDeleteTests: NextcloudFileProviderKitTestCase { ) } - func testDeleteLockFileWithoutCapabilitiesDoesNothing() async { + func testDeleteLockFileWithoutCapabilitiesRemovesLocalMetadataButKeepsServerLock() async { let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) XCTAssert(remoteInterface.capabilities.contains(##""locking": "1.0","##)) remoteInterface.capabilities = @@ -319,6 +319,7 @@ final class ItemDeleteTests: NextcloudFileProviderKitTestCase { ocId: "lock-id", fileName: lockFileName, account: Self.account ) lockFileMetadata.serverUrl += "/folder" + Self.dbManager.addItemMetadata(lockFileMetadata) let lockItem = Item( metadata: lockFileMetadata, @@ -330,13 +331,87 @@ final class ItemDeleteTests: NextcloudFileProviderKitTestCase { // Delete the lock file let error = await lockItem.delete(dbManager: Self.dbManager) - XCTAssertNil(Self.dbManager.itemMetadata(ocId: lockFileMetadata.ocId)) + // The local lock metadata is always removed, even without locking capability, so it is + // never orphaned in the database. + XCTAssertEqual(Self.dbManager.itemMetadata(ocId: lockFileMetadata.ocId)?.deleted, true) XCTAssertNil(error) + // No unlock request can be made without the capability, so the server lock is untouched. XCTAssertTrue( targetRemote.locked, "Expected the target file to still be locked" ) } + /// An Adobe lock file resolves its guarded document by sibling lookup, then unlocks it on the + /// server on deletion, mirroring the Office/LibreOffice behaviour. + func testDeleteAdobeLockFileUnlocksDocument() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + let folderRemote = MockRemoteItem( + identifier: "folder-id", + versionIdentifier: "1", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + let targetFileName = "MyDoc.indd" + let targetRemote = MockRemoteItem( + identifier: "folder/\(targetFileName)", + versionIdentifier: "1", + name: targetFileName, + remotePath: folderRemote.remotePath + "/" + targetFileName, + data: Data("test data".utf8), + locked: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + folderRemote.children = [targetRemote] + folderRemote.parent = rootItem + rootItem.children = [folderRemote] + + var folderMetadata = SendableItemMetadata( + ocId: folderRemote.identifier, fileName: "folder", account: Self.account + ) + folderMetadata.directory = true + Self.dbManager.addItemMetadata(folderMetadata) + + var targetMetadata = SendableItemMetadata( + ocId: targetRemote.identifier, fileName: targetFileName, account: Self.account + ) + targetMetadata.serverUrl += "/folder" + Self.dbManager.addItemMetadata(targetMetadata) + + let lockFileName = "~MyDoc~0kjyv(.idlk" + var lockFileMetadata = SendableItemMetadata( + ocId: "lock-id", fileName: lockFileName, account: Self.account + ) + lockFileMetadata.serverUrl += "/folder" + lockFileMetadata.isLockFileOfLocalOrigin = true + Self.dbManager.addItemMetadata(lockFileMetadata) + + let lockItem = Item( + metadata: lockFileMetadata, + parentItemIdentifier: .init(folderMetadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let error = await lockItem.delete(dbManager: Self.dbManager) + XCTAssertEqual(Self.dbManager.itemMetadata(ocId: lockFileMetadata.ocId)?.deleted, true) + XCTAssertNil(error) + XCTAssertFalse( + targetRemote.locked, "Expected the document to be unlocked after lock file deletion" + ) + } + func testFailOnNonRecursiveNonEmptyDirDelete() async { let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let remoteFolder = MockRemoteItem( diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/LocalFilesTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/LocalFilesTests.swift new file mode 100644 index 0000000000000..06ecfd72a54e5 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/LocalFilesTests.swift @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@testable import NextcloudFileProviderKit +import Testing + +struct LocalFilesTests { + @Test func recognisesLockFileNames() { + // Microsoft Office. + #expect(isLockFileName("~$Test.docx")) + // LibreOffice. + #expect(isLockFileName(".~lock.Test.odt#")) + // Adobe InDesign / InCopy. + #expect(isLockFileName("~Test~0kjyv(.idlk")) + #expect(isLockFileName("Test.idlk")) + #expect(isLockFileName("Test.IDLK")) // Case-insensitive extension. + // Adobe Premiere Pro. + #expect(isLockFileName("Test.prlock")) + + // Guarded documents themselves are not lock files. + #expect(!isLockFileName("Test.indd")) + #expect(!isLockFileName("Test.icml")) + #expect(!isLockFileName("Test.prproj")) + #expect(!isLockFileName("Test.docx")) + #expect(!isLockFileName("Test.odt")) + } + + @Test func recognisesAdobeLockFileNames() { + #expect(isAdobeLockFileName("~Test~0kjyv(.idlk")) + #expect(isAdobeLockFileName("Test.prlock")) + #expect(isAdobeLockFileName("Test.IdLk")) // Case-insensitive extension. + + #expect(!isAdobeLockFileName("~$Test.docx")) // Microsoft Office is not Adobe. + #expect(!isAdobeLockFileName(".~lock.Test.odt#")) // LibreOffice is not Adobe. + #expect(!isAdobeLockFileName("Test.indd")) + #expect(!isAdobeLockFileName("Test.prproj")) + } + + @Test func extractsAdobeDocumentBaseName() { + // InDesign / InCopy with a random token. + #expect(adobeLockFileDocumentBaseName("~Test~0kjyv(.idlk") == "Test") + // InDesign / InCopy without a token. + #expect(adobeLockFileDocumentBaseName("~Test(.idlk") == "Test") + // Base name containing a tilde is preserved (only the trailing token is dropped). + #expect(adobeLockFileDocumentBaseName("~My~Doc~0kjyv(.idlk") == "My~Doc") + // Premiere Pro. + #expect(adobeLockFileDocumentBaseName("Test.prlock") == "Test") + + // Non-Adobe extensions yield no base name. + #expect(adobeLockFileDocumentBaseName("Test.docx") == nil) + #expect(adobeLockFileDocumentBaseName("~$Test.docx") == nil) + } +}