Skip to content
Merged
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
531 changes: 531 additions & 0 deletions Sources/SortAI/App/OrganizationPreviewView.swift

Large diffs are not rendered by default.

45 changes: 44 additions & 1 deletion Sources/SortAI/Core/Configuration/AppConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ enum SortAIDefaultsKey {
static let autoInstallOllama = "autoInstallOllama"
static let enableFAISS = "enableFAISS"
static let useAppleEmbeddings = "useAppleEmbeddings"

// Hierarchy-aware categorization settings
static let respectHierarchy = "respectHierarchy"
static let minFilesForFolder = "minFilesForFolder"
static let allowUserFlatten = "allowUserFlatten"
static let folderReviewThreshold = "folderReviewThreshold"
}

/// Registers default values in UserDefaults at app startup
Expand Down Expand Up @@ -154,6 +160,12 @@ enum SortAIDefaults {
SortAIDefaultsKey.respectBatteryStatus: true,
SortAIDefaultsKey.enableWatchMode: false,
SortAIDefaultsKey.watchQuietPeriod: 3.0,

// Hierarchy-aware categorization
SortAIDefaultsKey.respectHierarchy: true,
SortAIDefaultsKey.minFilesForFolder: 1,
SortAIDefaultsKey.allowUserFlatten: true,
SortAIDefaultsKey.folderReviewThreshold: 0.75,
]

UserDefaults.standard.register(defaults: defaults)
Expand Down Expand Up @@ -381,12 +393,43 @@ struct OrganizationConfiguration: Codable, Sendable, Equatable {
/// Characters to replace in filenames
var invalidCharacters: String

// MARK: - Hierarchy Settings

/// Whether to respect folder hierarchy (treat sub-folders as units)
var respectHierarchy: Bool

/// Minimum files in a folder to treat it as a unit (folders with fewer files are flattened)
var minFilesForFolder: Int

/// Whether to allow users to flatten folders from the preview UI
var allowUserFlatten: Bool

/// Confidence threshold below which folders are flagged for review
var folderReviewThreshold: Double

static let `default` = OrganizationConfiguration(
defaultMode: .copy,
createMetadataFiles: false,
preserveTimestamps: true,
maxFilenameLength: 200,
invalidCharacters: "/\\:*?\"<>|"
invalidCharacters: "/\\:*?\"<>|",
respectHierarchy: true,
minFilesForFolder: 1,
allowUserFlatten: true,
folderReviewThreshold: 0.75
)

/// Legacy configuration without hierarchy awareness
static let flat = OrganizationConfiguration(
defaultMode: .copy,
createMetadataFiles: false,
preserveTimestamps: true,
maxFilenameLength: 200,
invalidCharacters: "/\\:*?\"<>|",
respectHierarchy: false,
minFilesForFolder: 1,
allowUserFlatten: false,
folderReviewThreshold: 0.75
)
}

Expand Down
262 changes: 262 additions & 0 deletions Sources/SortAI/Core/Organizer/OrganizationEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,165 @@ actor OrganizationEngine {
}
}

// MARK: - Hierarchy-Aware Planning

/// Plan organization with hierarchy awareness
/// Folders move as complete units, loose files move individually
func planHierarchyOrganization(
scanResult: HierarchyScanResult,
folderAssignments: [FolderCategoryAssignment],
fileAssignments: [FileAssignment],
tree: TaxonomyTree,
outputFolder: URL
) async -> HierarchyAwareOrganizationPlan {
NSLog("📋 [OrganizationEngine] Planning hierarchy-aware organization")
NSLog("📋 [OrganizationEngine] \(scanResult.folders.count) folders, \(scanResult.looseFiles.count) loose files")

var folderOps: [FolderOrganizationOperation] = []
var fileOps: [OrganizationOperation] = []
var folderConflicts: [FolderOrganizationConflict] = []
var fileConflicts: [OrganizationConflict] = []

// Build assignment lookups
let folderAssignmentMap = Dictionary(
folderAssignments.map { ($0.folderId, $0) },
uniquingKeysWith: { first, _ in first }
)

let fileAssignmentMap = Dictionary(
fileAssignments.map { ($0.fileId, $0) },
uniquingKeysWith: { first, _ in first }
)

// Plan folder operations
for folder in scanResult.folders {
guard let assignment = folderAssignmentMap[folder.id] else {
// Unassigned folder - use Uncategorized
let destFolder = outputFolder.appendingPathComponent(config.uncategorizedFolderName)
let destPath = destFolder.appendingPathComponent(folder.folderName)

if fileManager.fileExists(atPath: destPath.path) {
folderConflicts.append(FolderOrganizationConflict(
sourceFolder: folder,
destinationPath: destPath,
resolution: .askUser
))
} else {
folderOps.append(FolderOrganizationOperation(
sourceFolder: folder,
destinationFolder: destFolder,
destinationCategory: config.uncategorizedFolderName,
confidence: 0.3,
mode: config.mode
))
}
continue
}

// Build destination from category path
let categoryPath = assignment.categoryPath
let destFolder = categoryPath.reduce(outputFolder) { $0.appendingPathComponent($1) }
let destPath = destFolder.appendingPathComponent(folder.folderName)

// Check for conflicts
if fileManager.fileExists(atPath: destPath.path) {
folderConflicts.append(FolderOrganizationConflict(
sourceFolder: folder,
destinationPath: destPath,
resolution: .askUser
))
} else {
folderOps.append(FolderOrganizationOperation(
sourceFolder: folder,
destinationFolder: destFolder,
destinationCategory: categoryPath.joined(separator: " / "),
confidence: assignment.confidence,
mode: config.mode
))
}
}

// Plan loose file operations (same as regular planning)
for file in scanResult.looseFiles {
guard let assignment = fileAssignmentMap[file.id] else {
// Unassigned file
let uncategorizedFolder = outputFolder.appendingPathComponent(config.uncategorizedFolderName)
let dest = uncategorizedFolder.appendingPathComponent(file.filename)

if fileManager.fileExists(atPath: dest.path) {
fileConflicts.append(OrganizationConflict(
sourceFile: file,
destinationPath: dest,
resolution: .askUser
))
} else {
fileOps.append(OrganizationOperation(
sourceFile: file,
destinationFolder: uncategorizedFolder,
destinationPath: dest,
mode: config.mode
))
}
continue
}

// Build destination from category
guard let node = tree.node(byId: assignment.categoryId) else {
let uncategorizedFolder = outputFolder.appendingPathComponent(config.uncategorizedFolderName)
let dest = uncategorizedFolder.appendingPathComponent(file.filename)

if fileManager.fileExists(atPath: dest.path) {
fileConflicts.append(OrganizationConflict(
sourceFile: file,
destinationPath: dest,
resolution: .askUser
))
} else {
fileOps.append(OrganizationOperation(
sourceFile: file,
destinationFolder: uncategorizedFolder,
destinationPath: dest,
mode: config.mode
))
}
continue
}

let categoryPath = tree.pathToNode(node)
let destFolder = categoryPath.reduce(outputFolder) { $0.appendingPathComponent($1.name) }
let destFile = destFolder.appendingPathComponent(file.filename)

if fileManager.fileExists(atPath: destFile.path) {
fileConflicts.append(OrganizationConflict(
sourceFile: file,
destinationPath: destFile,
resolution: .askUser
))
} else {
fileOps.append(OrganizationOperation(
sourceFile: file,
destinationFolder: destFolder,
destinationPath: destFile,
mode: config.mode
))
}
}

let totalSize = folderOps.reduce(0) { $0 + $1.sourceFolder.totalSize } +
fileOps.reduce(0) { $0 + $1.sourceFile.fileSize }

NSLog("📋 [OrganizationEngine] Plan complete: \(folderOps.count) folder ops, \(fileOps.count) file ops")
NSLog("📋 [OrganizationEngine] Conflicts: \(folderConflicts.count) folder, \(fileConflicts.count) file")

return HierarchyAwareOrganizationPlan(
folderOperations: folderOps,
fileOperations: fileOps,
folderConflicts: folderConflicts,
fileConflicts: fileConflicts,
estimatedSize: totalSize
)
}

// MARK: - Execution

/// Execute the organization plan
Expand Down Expand Up @@ -383,3 +542,106 @@ struct FailedOperation: Sendable {
let error: String
}


// MARK: - Hierarchy-Aware Organization Types

/// Operation for moving a folder as a complete unit
struct FolderOrganizationOperation: Sendable, Identifiable {
let id: UUID
let sourceFolder: ScannedFolder
let destinationFolder: URL // Where the folder will be moved to
let destinationCategory: String // Category name for display
let confidence: Double
let preserveInternalStructure: Bool // Always true for folder units
let mode: OrganizationMode

init(
id: UUID = UUID(),
sourceFolder: ScannedFolder,
destinationFolder: URL,
destinationCategory: String,
confidence: Double,
mode: OrganizationMode
) {
self.id = id
self.sourceFolder = sourceFolder
self.destinationFolder = destinationFolder
self.destinationCategory = destinationCategory
self.confidence = confidence
self.preserveInternalStructure = true
self.mode = mode
}
}

/// Conflict when organizing a folder
final class FolderOrganizationConflict: @unchecked Sendable, Identifiable {
let id = UUID()
let sourceFolder: ScannedFolder
let destinationPath: URL
var resolution: ConflictResolution

init(sourceFolder: ScannedFolder, destinationPath: URL, resolution: ConflictResolution) {
self.sourceFolder = sourceFolder
self.destinationPath = destinationPath
self.resolution = resolution
}
}

/// Organization plan that respects folder hierarchy
/// Separates folder operations from individual file operations
struct HierarchyAwareOrganizationPlan: Sendable {
let folderOperations: [FolderOrganizationOperation]
let fileOperations: [OrganizationOperation]
let folderConflicts: [FolderOrganizationConflict]
let fileConflicts: [OrganizationConflict]
let estimatedSize: Int64

/// Total number of items to organize
var totalItems: Int {
folderOperations.count + fileOperations.count
}

/// Total file count (including files inside folders)
var totalFileCount: Int {
let folderFiles = folderOperations.reduce(0) { $0 + $1.sourceFolder.fileCount }
return folderFiles + fileOperations.count
}

/// Whether there are any conflicts to resolve
var hasConflicts: Bool {
!folderConflicts.isEmpty || !fileConflicts.isEmpty
}

/// Convert to legacy OrganizationPlan (flattens folders into individual file ops)
func toLegacyPlan() -> OrganizationPlan {
var allFileOps = fileOperations

// Flatten folder operations into file operations
for folderOp in folderOperations {
for file in folderOp.sourceFolder.containedFiles {
let destPath = folderOp.destinationFolder
.appendingPathComponent(folderOp.sourceFolder.folderName)
.appendingPathComponent(file.relativePath.replacingOccurrences(
of: folderOp.sourceFolder.relativePath + "/",
with: ""
))

allFileOps.append(OrganizationOperation(
sourceFile: file,
destinationFolder: destPath.deletingLastPathComponent(),
destinationPath: destPath,
mode: folderOp.mode
))
}
}

// Combine conflicts
let allConflicts = fileConflicts

return OrganizationPlan(
operations: allFileOps,
conflicts: allConflicts,
estimatedSize: estimatedSize
)
}
}
Loading
Loading