Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}

///
Expand Down Expand Up @@ -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[..<lastTilde])
}

return stem.isEmpty ? nil : stem
case "prlock":
// Premiere Pro: `{base name}.prlock`.
return stem.isEmpty ? nil : stem
default:
return nil
}
}

///
/// Resolve the document guarded by an Adobe lock file by matching a sibling file in the same
/// directory by base name and expected document extension.
///
/// - Parameters:
/// - lockFilename: The Adobe lock file name.
/// - parentServerUrl: The server URL of the directory containing the lock file.
/// - dbManager: The database manager to use for looking up sibling files.
///
/// - Returns: The guarded document's file name, or `nil` if no matching document is found.
///
func adobeLockFileTargetName(lockFilename: String, parentServerUrl: String, dbManager: FilesDatabaseManager) -> 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)
}
Loading
Loading