@@ -9,12 +9,13 @@ import Foundation
99import AppKit
1010
1111struct QRItem {
12- let path : String // relative path within library (we use just filename for CSV schema)
12+ let relativePath : String // relative file path within the library
1313 let text : String
1414}
1515
1616extension Notification . Name {
1717 static let LibraryDidChange = Notification . Name ( " LibraryDidChange " )
18+ static let LibraryDidClear = Notification . Name ( " LibraryDidClear " )
1819}
1920
2021enum LibraryTransfer {
@@ -45,22 +46,24 @@ enum LibraryTransfer {
4546 if values? . isDirectory == true { continue }
4647 if FileScanner . allowedExtensions. contains ( item. pathExtension. lowercased ( ) ) ,
4748 let text = try ? String ( contentsOf: item, encoding: . utf8) {
48- // Export using only filename for simplicity
49- let filename = item. lastPathComponent
50- results. append ( QRItem ( path: filename, text: text) )
49+ let relative = relativePath ( for: item, root: root)
50+ results. append ( QRItem ( relativePath: relative, text: text) )
5151 }
5252 }
53- return results. sorted { $0. path . localizedCaseInsensitiveCompare ( $1. path ) == . orderedAscending }
53+ return results. sorted { $0. relativePath . localizedCaseInsensitiveCompare ( $1. relativePath ) == . orderedAscending }
5454 }
5555
5656 // Apply snapshot into library (overwrite existing files)
5757 static func applySnapshot( _ items: [ QRItem ] ) throws {
5858 guard let root = appLibraryRoot ( ) else { return }
5959 let fm = FileManager . default
6060 for item in items {
61- let dest = uniqueFileURL ( root: root, preferredFilename: ensureAllowedExtensionForFilename ( item. path) )
61+ let relativePath = sanitizedRelativePath ( item. relativePath)
62+ guard !relativePath. isEmpty else { continue }
63+ let dest = uniqueFileURL ( root: root, preferredRelativePath: relativePath)
6264 try fm. createDirectory ( at: dest. deletingLastPathComponent ( ) , withIntermediateDirectories: true )
63- try item. text. data ( using: . utf8) ? . write ( to: dest)
65+ guard let data = item. text. data ( using: . utf8) else { continue }
66+ try data. write ( to: dest)
6467 }
6568 NotificationCenter . default. post ( name: . LibraryDidChange, object: nil )
6669 }
@@ -97,6 +100,30 @@ enum LibraryTransfer {
97100 }
98101 }
99102
103+ static func clearLibrary( ) {
104+ guard let root = appLibraryRoot ( ) else { return }
105+ let alert = NSAlert ( )
106+ alert. messageText = " Delete all saved QR entries? "
107+ alert. informativeText = " This removes every file and folder in the app library. This action cannot be undone. "
108+ alert. alertStyle = . warning
109+ alert. addButton ( withTitle: " Delete All " )
110+ alert. addButton ( withTitle: " Cancel " )
111+ let response = alert. runModal ( )
112+ if response == . alertFirstButtonReturn {
113+ let fm = FileManager . default
114+ do {
115+ let contents = try fm. contentsOfDirectory ( at: root, includingPropertiesForKeys: nil , options: [ ] )
116+ for item in contents {
117+ try fm. removeItem ( at: item)
118+ }
119+ NotificationCenter . default. post ( name: . LibraryDidClear, object: nil )
120+ NotificationCenter . default. post ( name: . LibraryDidChange, object: nil )
121+ } catch {
122+ NSSound . beep ( )
123+ }
124+ }
125+ }
126+
100127 private static func defaultExportName( ) -> String {
101128 let f = DateFormatter ( )
102129 f. locale = Locale ( identifier: " en_US_POSIX " )
@@ -114,23 +141,22 @@ enum LibraryTransfer {
114141 }
115142
116143 private static func buildCSV( from items: [ QRItem ] ) -> String {
117- // Simple, hand-editable schema: filename,text,order
118- // - No quoting required
119- // - Replace newlines in text with literal \n
120- var out = " filename,text,order \n "
144+ var lines : [ String ] = [ ]
145+ lines. append ( [ " folder " , " filename " , " text " , " order " ] . map ( csvEscape) . joined ( separator: " , " ) )
121146 var order = 1
122147 for item in items {
123- var filename = URL ( fileURLWithPath: item. path) . lastPathComponent
124- // Avoid commas in filename to keep CSV very simple
125- filename = filename. replacingOccurrences ( of: " , " , with: " - " )
126- let textEscaped = item. text
127- . replacingOccurrences ( of: " \r \n " , with: " \n " )
128- . replacingOccurrences ( of: " \r " , with: " \n " )
129- . replacingOccurrences ( of: " \n " , with: " \\ n " )
130- out += filename + " , " + textEscaped + " , " + String( order) + " \n "
148+ let parts = splitRelativePath ( item. relativePath)
149+ let normalizedText = normalizeNewlines ( item. text) . replacingOccurrences ( of: " \n " , with: " \\ n " )
150+ let fields = [
151+ csvEscape ( parts. folder) ,
152+ csvEscape ( parts. filename) ,
153+ csvEscape ( normalizedText) ,
154+ csvEscape ( String ( order) )
155+ ]
156+ lines. append ( fields. joined ( separator: " , " ) )
131157 order += 1
132158 }
133- return out
159+ return lines . joined ( separator : " \n " ) + " \n "
134160 }
135161
136162 private static func normalizeNewlines( _ s: String ) -> String { s. replacingOccurrences ( of: " \r \n " , with: " \n " ) . replacingOccurrences ( of: " \r " , with: " \n " ) }
@@ -193,61 +219,130 @@ enum LibraryTransfer {
193219
194220 private static func parseCSVToItems( _ csv: String ) -> [ QRItem ] {
195221 var rows = parseCSVRows ( csv)
196- if let first = rows. first, first. count >= 2 {
197- let h0 = first [ 0 ] . trimmingCharacters ( in: . whitespacesAndNewlines) . lowercased ( )
198- let h1 = first [ 1 ] . trimmingCharacters ( in: . whitespacesAndNewlines) . lowercased ( )
199- if ( h0 == " filename " && h1 == " text " ) { rows. removeFirst ( ) }
222+ guard !rows. isEmpty else { return [ ] }
223+
224+ var folderIndex : Int ? = nil
225+ var filenameIndex : Int = 0
226+ var textStartIndex : Int = 1
227+ var orderIndex : Int ? = nil
228+
229+ if let first = rows. first {
230+ let normalized = first. map { $0. trimmingCharacters ( in: . whitespacesAndNewlines) . lowercased ( ) }
231+ if normalized. contains ( " filename " ) {
232+ if let idx = normalized. firstIndex ( of: " folder " ) { folderIndex = idx }
233+ if let idx = normalized. firstIndex ( of: " filename " ) { filenameIndex = idx }
234+ if let idx = normalized. firstIndex ( of: " text " ) {
235+ textStartIndex = idx
236+ } else {
237+ textStartIndex = max ( filenameIndex, folderIndex ?? filenameIndex) + 1
238+ }
239+ if let idx = normalized. firstIndex ( of: " order " ) { orderIndex = idx }
240+ rows. removeFirst ( )
241+ }
200242 }
243+
201244 var items : [ QRItem ] = [ ]
202245 for row in rows {
203246 if row. isEmpty { continue }
204- let filenameRaw = row. first ?? " "
205- // Join the middle columns as text to support unquoted commas in text
206- var textRaw = " "
207- if row. count >= 3 , Int ( row. last!. trimmingCharacters ( in: . whitespaces) ) != nil {
208- textRaw = row [ 1 ..< ( row. count- 1 ) ] . joined ( separator: " , " )
209- } else if row. count >= 2 {
210- textRaw = row [ 1 ... ] . joined ( separator: " , " )
247+
248+ let filenameRaw : String
249+ if filenameIndex < row. count {
250+ filenameRaw = row [ filenameIndex]
251+ } else {
252+ filenameRaw = row. first ?? " "
253+ }
254+
255+ let folderRaw : String
256+ if let folderIdx = folderIndex, folderIdx < row. count {
257+ folderRaw = row [ folderIdx]
258+ } else {
259+ folderRaw = " "
260+ }
261+
262+ var orderColumn : Int ? = nil
263+ if let idx = orderIndex, idx < row. count,
264+ Int ( row [ idx] . trimmingCharacters ( in: . whitespaces) ) != nil {
265+ orderColumn = idx
266+ } else if let last = row. last,
267+ Int ( last. trimmingCharacters ( in: . whitespaces) ) != nil ,
268+ row. count > max ( filenameIndex, ( folderIndex ?? - 1 ) ) + 1 {
269+ orderColumn = row. count - 1
270+ }
271+
272+ let textStartCandidate = max ( textStartIndex, max ( filenameIndex + 1 , ( folderIndex ?? - 1 ) + 1 ) )
273+ let startIndex = min ( textStartCandidate, row. count)
274+ var endIndex = row. count
275+ if let orderIdx = orderColumn {
276+ endIndex = min ( orderIdx, row. count)
277+ }
278+ if startIndex >= endIndex { continue }
279+ let textFields = row [ startIndex..< endIndex]
280+ let textRaw = textFields. joined ( separator: " , " )
281+
282+ let sanitizedFolder = FileScanner . sanitizedFolderPath ( folderRaw)
283+ let fallbackName = filenameRaw. isEmpty ? " Imported " : filenameRaw
284+ let sanitizedFile = FileScanner . sanitizedFileName ( filenameRaw, fallback: fallbackName)
285+ let relativePath : String
286+ if sanitizedFolder. isEmpty {
287+ relativePath = sanitizedFile
211288 } else {
212- continue
289+ relativePath = sanitizedFolder + " / " + sanitizedFile
213290 }
214- let filename = sanitizeFilename ( ensureAllowedExtensionForFilename ( filenameRaw) )
215- guard !filename. isEmpty else { continue }
216291 let text = textRaw. replacingOccurrences ( of: " \\ n " , with: " \n " )
217- items. append ( QRItem ( path : filename , text: text) )
292+ items. append ( QRItem ( relativePath : relativePath , text: text) )
218293 }
219294 return items
220295 }
221296
222- private static func sanitizeFilename( _ name: String ) -> String {
223- var base = name. trimmingCharacters ( in: . whitespacesAndNewlines)
224- // remove any path components
225- base = URL ( fileURLWithPath: base) . lastPathComponent
226- if base. isEmpty { return " " }
227- // replace illegal filename characters and commas
228- base = base. replacingOccurrences ( of: " [ \\ \\ /:*? \" <>|,] " , with: " - " , options: . regularExpression)
229- return base
230- }
231-
232- private static func ensureAllowedExtensionForFilename( _ name: String ) -> String {
233- let url = URL ( fileURLWithPath: name)
234- let ext = url. pathExtension. lowercased ( )
235- if FileScanner . allowedExtensions. contains ( ext) { return url. lastPathComponent }
236- if ext. isEmpty { return url. lastPathComponent + " .txt " }
237- return url. deletingPathExtension ( ) . lastPathComponent + " .txt "
238- }
239-
240- private static func uniqueFileURL( root: URL , preferredFilename: String ) -> URL {
241- var url = root. appendingPathComponent ( preferredFilename)
297+ private static func uniqueFileURL( root: URL , preferredRelativePath: String ) -> URL {
298+ let initial = URL ( fileURLWithPath: preferredRelativePath, relativeTo: root) . standardizedFileURL
242299 let fm = FileManager . default
243- if !fm. fileExists ( atPath: url. path) { return url }
244- let base = url. deletingPathExtension ( ) . lastPathComponent
245- let ext = url. pathExtension
300+ if !fm. fileExists ( atPath: initial. path) { return initial }
301+ let directory = initial. deletingLastPathComponent ( )
302+ let ext = initial. pathExtension
303+ let base = initial. deletingPathExtension ( ) . lastPathComponent
246304 var i = 1
247305 while true {
248- let candidate = root. appendingPathComponent ( " \( base) - \( i) . \( ext) " )
306+ let candidateName = ext. isEmpty ? " \( base) - \( i) " : " \( base) - \( i) . \( ext) "
307+ let candidate = directory. appendingPathComponent ( candidateName)
249308 if !fm. fileExists ( atPath: candidate. path) { return candidate }
250309 i += 1
251310 }
252311 }
312+
313+ private static func relativePath( for url: URL , root: URL ) -> String {
314+ let absolute = url. standardizedFileURL. path
315+ let rootPath = root. standardizedFileURL. path
316+ if absolute. hasPrefix ( rootPath) {
317+ var relative = String ( absolute. dropFirst ( rootPath. count) )
318+ if relative. hasPrefix ( " / " ) { relative. removeFirst ( ) }
319+ return relative
320+ }
321+ return url. lastPathComponent
322+ }
323+
324+ private static func splitRelativePath( _ relativePath: String ) -> ( folder: String , filename: String ) {
325+ if let range = relativePath. range ( of: " / " , options: . backwards) {
326+ let folder = String ( relativePath [ ..< range. lowerBound] )
327+ let filename = String ( relativePath [ range. upperBound... ] )
328+ return ( folder, filename)
329+ }
330+ return ( " " , relativePath)
331+ }
332+
333+ private static func sanitizedRelativePath( _ raw: String ) -> String {
334+ let trimmed = raw. trimmingCharacters ( in: . whitespacesAndNewlines)
335+ if trimmed. isEmpty {
336+ return FileScanner . sanitizedFileName ( " Imported " , fallback: " Imported " )
337+ }
338+ let components = trimmed. split ( separator: " / " ) . map ( String . init)
339+ guard let fileComponent = components. last else {
340+ return FileScanner . sanitizedFileName ( " Imported " , fallback: " Imported " )
341+ }
342+ let folderComponents = components. dropLast ( ) . map { FileScanner . sanitizeFolderComponent ( $0) } . filter { !$0. isEmpty }
343+ let sanitizedFile = FileScanner . sanitizedFileName ( fileComponent, fallback: fileComponent. isEmpty ? " Imported " : fileComponent)
344+ var parts = folderComponents
345+ parts. append ( sanitizedFile)
346+ return parts. joined ( separator: " / " )
347+ }
253348}
0 commit comments