Skip to content

Commit 6bbcea4

Browse files
committed
Added QR code manager app
1 parent c37e157 commit 6bbcea4

File tree

7 files changed

+447
-100
lines changed

7 files changed

+447
-100
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
All notable changes to this project will be documented here.
44

55
## [Unreleased]
6+
- Folder-aware library management with CSV export/import preserving subfolders
7+
- File menu option to clear all saved QR files/folders with confirmation
68
- Export/copy QR image
79
- Adjustable size and correction level
810

911
## [0.1.0] - 2025-09-12
1012
- Initial public structure and docs
1113
- Basic SwiftUI app to generate QR from text
12-

QRCodeGenerator/QRCodeGenerator/ContentView.swift

Lines changed: 197 additions & 36 deletions
Large diffs are not rendered by default.

QRCodeGenerator/QRCodeGenerator/FileManagement.swift

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ struct FileNode: Identifiable, Hashable {
2424
enum FileScanner {
2525
static let allowedExtensions: Set<String> = ["txt", "qr", "qrtext"]
2626

27+
// MARK: - Directory tree helpers
28+
2729
static func buildTree(at root: URL) -> FileNode? {
2830
let fm = FileManager.default
2931
var isDir: ObjCBool = false
@@ -66,5 +68,44 @@ enum FileScanner {
6668
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
6769
try text.data(using: .utf8)?.write(to: url)
6870
}
69-
}
7071

72+
// MARK: - Name sanitization
73+
74+
static func sanitizeFileComponent(_ name: String) -> String {
75+
var base = name.trimmingCharacters(in: .whitespacesAndNewlines)
76+
base = base.replacingOccurrences(of: "\\s+", with: "_", options: .regularExpression)
77+
.replacingOccurrences(of: "[\\\\/:*?\"<>|]", with: "-", options: .regularExpression)
78+
return base
79+
}
80+
81+
static func ensureAllowedExtension(for filename: String) -> String {
82+
let url = URL(fileURLWithPath: filename)
83+
let ext = url.pathExtension.lowercased()
84+
if allowedExtensions.contains(ext) {
85+
return url.lastPathComponent
86+
}
87+
let base = url.deletingPathExtension().lastPathComponent
88+
return (base.isEmpty ? "QRText" : base) + ".txt"
89+
}
90+
91+
static func sanitizedFileName(_ raw: String, fallback: String) -> String {
92+
var candidate = sanitizeFileComponent(raw)
93+
if candidate.isEmpty {
94+
candidate = sanitizeFileComponent(fallback)
95+
}
96+
return ensureAllowedExtension(for: candidate)
97+
}
98+
99+
static func sanitizeFolderComponent(_ name: String) -> String {
100+
var base = name.trimmingCharacters(in: .whitespacesAndNewlines)
101+
if base == "." || base == ".." { return "" }
102+
base = base.replacingOccurrences(of: "\\s+", with: "_", options: .regularExpression)
103+
.replacingOccurrences(of: "[\\\\/:*?\"<>|]", with: "-", options: .regularExpression)
104+
return base
105+
}
106+
107+
static func sanitizedFolderPath(_ rawPath: String) -> String {
108+
let components = rawPath.split(separator: "/").map { sanitizeFolderComponent(String($0)) }.filter { !$0.isEmpty }
109+
return components.joined(separator: "/")
110+
}
111+
}

QRCodeGenerator/QRCodeGenerator/LibraryTransfer.swift

Lines changed: 154 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import Foundation
99
import AppKit
1010

1111
struct 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

1616
extension Notification.Name {
1717
static let LibraryDidChange = Notification.Name("LibraryDidChange")
18+
static let LibraryDidClear = Notification.Name("LibraryDidClear")
1819
}
1920

2021
enum 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
}

QRCodeGenerator/QRCodeGenerator/QRCodeGeneratorApp.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ struct QRCodeGeneratorApp: App {
1818
CommandGroup(after: .importExport) {
1919
Button("Import Library…") { LibraryTransfer.importLibrary() }
2020
Button("Export Library…") { LibraryTransfer.exportLibrary() }
21+
Divider()
22+
Button("Clear Library…", role: .destructive) { LibraryTransfer.clearLibrary() }
2123
}
2224
}
2325
}

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ TextToQR renders a QR code for any text you type. It uses the `CIQRCodeGenerator
1616
## Features
1717

1818
- App-managed storage: files saved under Application Support in the app container (no folder selection)
19-
- Folder-based management within the app’s library; browse subfolders/files
19+
- Folder-based management within the app’s library; create subfolders, browse, and save into them
2020
- Load/save QR texts as files (`.txt`, `.qr`, `.qrtext`)
21-
- Export/Import the entire library as a single `.csv` file (File menu)
21+
- Export/Import the entire library as a single `.csv` file and clear the saved library from the File menu
2222
- Edit and generate QR without saving; choose to Save or Save As later
2323
- Live preview as you type (no Render button)
2424
- Core Image–based QR generation with error correction
@@ -32,7 +32,7 @@ TextToQR renders a QR code for any text you type. It uses the `CIQRCodeGenerator
3232
- Start typing to generate a QR without saving; click Save or Save As to persist within the app library.
3333
- Or select a file to load and regenerate its QR; edit and Save to update that file.
3434
- Use File → Export Library… to save a snapshot (`.csv`), and File → Import Library… to merge from a snapshot.
35-
- CSV schema (simple): header `filename,text,order`. Each row has the filename only (no folders), the QR text, and a serial number for readability. Fields are quoted and quotes inside a field are doubled.
35+
- CSV schema: header `folder,filename,text,order`. `folder` is a relative path inside the library (blank for the root). Text is stored with literal `\n`, and `order` preserves the display ordering.
3636

3737
See `docs/DEVELOPMENT.md` for detailed setup instructions.
3838

0 commit comments

Comments
 (0)