Skip to content

Commit

Permalink
Added a better application architecture
Browse files Browse the repository at this point in the history
  • Loading branch information
tom-ludwig committed Nov 16, 2023
1 parent 3f3cb8e commit d4d9055
Show file tree
Hide file tree
Showing 7 changed files with 473 additions and 287 deletions.
8 changes: 8 additions & 0 deletions SearchKitDemo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
619DD8012AF962A000A9364E /* SearchIndexer+AsyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 619DD8002AF962A000A9364E /* SearchIndexer+AsyncManager.swift */; };
61A4A3402B013DC6004802D7 /* SearchResultsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A4A33F2B013DC6004802D7 /* SearchResultsViewModel.swift */; };
61CA4C7D2AFAC205005AC971 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61CA4C7C2AFAC205005AC971 /* SidebarView.swift */; };
61CF23112B061FBF003B3C48 /* SearchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61CF23102B061FBF003B3C48 /* SearchManager.swift */; };
61CF23132B062247003B3C48 /* String+AppearancesOfSubstring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61CF23122B062247003B3C48 /* String+AppearancesOfSubstring.swift */; };
61E2E7802AF3EDDC00E3AA4A /* SearchIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E2E77F2AF3EDDC00E3AA4A /* SearchIndexer.swift */; };
61E2E7822AF3F7F800E3AA4A /* SearchIndexer+Add.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E2E7812AF3F7F800E3AA4A /* SearchIndexer+Add.swift */; };
61FE682F2B03FD7D0059E0FD /* String+SafeOffset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FE682E2B03FD7D0059E0FD /* String+SafeOffset.swift */; };
Expand Down Expand Up @@ -83,6 +85,8 @@
619DD8002AF962A000A9364E /* SearchIndexer+AsyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchIndexer+AsyncManager.swift"; sourceTree = "<group>"; };
61A4A33F2B013DC6004802D7 /* SearchResultsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewModel.swift; sourceTree = "<group>"; };
61CA4C7C2AFAC205005AC971 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
61CF23102B061FBF003B3C48 /* SearchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchManager.swift; sourceTree = "<group>"; };
61CF23122B062247003B3C48 /* String+AppearancesOfSubstring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AppearancesOfSubstring.swift"; sourceTree = "<group>"; };
61E2E77F2AF3EDDC00E3AA4A /* SearchIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchIndexer.swift; sourceTree = "<group>"; };
61E2E7812AF3F7F800E3AA4A /* SearchIndexer+Add.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchIndexer+Add.swift"; sourceTree = "<group>"; };
61FE682E2B03FD7D0059E0FD /* String+SafeOffset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SafeOffset.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -141,6 +145,8 @@
617658832AF161AB000C5197 /* SearchKitDemoApp.swift */,
617658852AF161AB000C5197 /* SearchKitDemoDocument.swift */,
617658872AF161AB000C5197 /* ContentView.swift */,
61CF23122B062247003B3C48 /* String+AppearancesOfSubstring.swift */,
61CF23102B061FBF003B3C48 /* SearchManager.swift */,
61CA4C7C2AFAC205005AC971 /* SidebarView.swift */,
61FE68302B03FEFF0059E0FD /* FileTabItemView.swift */,
61FE68322B0400720059E0FD /* SearchResultsFileTabItem.swift */,
Expand Down Expand Up @@ -338,6 +344,8 @@
61FE68312B03FEFF0059E0FD /* FileTabItemView.swift in Sources */,
617658862AF161AB000C5197 /* SearchKitDemoDocument.swift in Sources */,
6129F8832AF556B000A10F67 /* SearchIndexer+Memory.swift in Sources */,
61CF23132B062247003B3C48 /* String+AppearancesOfSubstring.swift in Sources */,
61CF23112B061FBF003B3C48 /* SearchManager.swift in Sources */,
61FE68332B0400720059E0FD /* SearchResultsFileTabItem.swift in Sources */,
61E2E7822AF3F7F800E3AA4A /* SearchIndexer+Add.swift in Sources */,
617658B42AF16B04000C5197 /* FileViewModel.swift in Sources */,
Expand Down
278 changes: 10 additions & 268 deletions SearchKitDemo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,23 @@ import SwiftUI
import AppKit

struct ContentView: View {
@State var files = [FileViewModel]()
@State var searchResults = [SearchResultsViewModel]()
@State var indexer = SearchIndexer.Memory.Create()
@State private var elapsedTime: TimeInterval?
@State private var searchTime: TimeInterval?
@ObservedObject var searchManager: SearchManager = SearchManager()
@State private var searchQuery: String = ""
@State private var asyncIndexing: Bool = false
var body: some View {
NavigationView {
SidebarView(files: $files, searchResults: $searchResults, removeAction: delete)
SidebarView(files: $searchManager.files, searchResults: $searchManager.searchResults, removeAction: searchManager.delete)

if !searchResults.isEmpty {
if !searchManager.searchResults.isEmpty {
VStack {
Text("Files Found: \(searchResults.count) within \(searchTime?.description ?? "0") seconds")
Text("Files Found: \(searchManager.searchResults.count).")
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.fill(.thinMaterial)
)

Table(searchResults) {
Table(searchManager.searchResults) {
TableColumn("Name") {
Text($0.url.lastPathComponent)
}
Expand All @@ -48,16 +44,16 @@ struct ContentView: View {
}

Button {
addFilesWithContentText()
searchManager.addFilesWithContentText()
} label: {
Image(systemName: "folder.badge.plus")
}

Button {
if asyncIndexing {
asyncIndex()
searchManager.asyncIndex()
} else {
index()
searchManager.index()
}
} label: {
Image(systemName: "square.grid.3x3.square")
Expand All @@ -66,273 +62,19 @@ struct ContentView: View {
TextField("Query...", text: $searchQuery)
.frame(minWidth: 100)
.onSubmit {
search()
searchManager.search(searchQuery: searchQuery)
}

Button {
search()
searchManager.search(searchQuery: searchQuery)
} label: {
Image(systemName: "magnifyingglass")
}
}
}

func delete(url: URL) -> Bool {
guard let indexer = indexer else {
return false
}
let success = indexer.remove(url: url)

if success {
files.removeAll {
$0.url == url
}
}

return success
}

private func asyncSearch() {
searchResults = [SearchResultsViewModel]()
let startTime = Date()

guard let index = indexer else {
return
}
let asyncController = SearchIndexer.AsyncManager(index: index)
asyncController.next(query: searchQuery, 10, timeout: 1.0) { results in
print(results)
}

let endTime = Date()
searchTime = endTime.timeIntervalSince(startTime)
print(searchTime as Any)
}

private func search() {
var newSearchResults = [SearchResultsViewModel]()
let startTime = Date()
let results = indexer?.search(searchQuery)
guard let results = results else {
print("No results found")
return
}
print(results.count)
for result in results {
let newResult = SearchResultsViewModel(url: result.url, score: result.score, lineMatches: [SearchResultLineMatchesModel]())
newSearchResults.append(newResult)
}
evaluateResults(query: searchQuery, searchResults: &newSearchResults)

// searchResults = searchResults?.sorted {
// $0.score > $1.score
// }
searchResults = newSearchResults
let endTime = Date()
searchTime = endTime.timeIntervalSince(startTime)
print(searchTime as Any)
}

private func evaluateResults(query: String, searchResults: inout [SearchResultsViewModel]) {

searchResults = searchResults.map { result in
var newResult = result
var newMatches = [SearchResultLineMatchesModel]()
guard let data = try? Data(contentsOf: result.url), let string = String(data: data, encoding: .utf8) else {
return newResult
}

for (lineNumber, line) in string.split(separator: "\n").lazy.enumerated() {
let rawNoSapceLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
let noSpaceLine = rawNoSapceLine.lowercased()

if lineContainsSearchTerm(line: noSpaceLine, term: query) {
let matches = noSpaceLine.ranges(of: query).map { range in
return [lineNumber, noSpaceLine, range]
}
for match in matches {
newMatches.append(SearchResultLineMatchesModel(file: result.url, lineNumber: match[0] as! Int, lineContent: match[1] as! String, keywordRange: match[2] as! Range<String.Index>))
}
// result.lineMatches.append(contentsOf: newMatches)
}
}
newMatches.forEach { match in
newResult.lineMatches.append(match)
}
return newResult
}
}

func lineContainsSearchTerm(line: String, term: String) -> Bool {
var line = line
if line.hasPrefix(" ") { line.removeFirst() }
if line.hasSuffix(" ") { line.removeLast() }

let textContainsSearchTerm = line.contains(searchQuery)
guard textContainsSearchTerm else { return false }

let appearances = line.appearancesOfSubstring(substring: term, toLeft: 1, toRight: 1)
var foundMatch = false
for appearance in appearances {
let appearanceString = String(line[appearance])
guard appearanceString.count >= 2 else { continue }

var startsWith = false
var endsWith = false
if appearanceString.hasPrefix(term) ||
!appearanceString.first!.isLetter ||
!appearanceString.character(at: 2).isLetter {
startsWith = true
}
if appearanceString.hasSuffix(term) ||
!appearanceString.last!.isLetter ||
!appearanceString.character(at: appearanceString.count-2).isLetter {
endsWith = true
}

// only matching for now
return startsWith && endsWith ? true : false

// switch textMatching {
// case .MatchingWord:
// foundMatch = startsWith && endsWith ? true : foundMatch
// case .StartingWith:
// foundMatch = startsWith ? true : foundMatch
// case .EndingWith:
// foundMatch = endsWith ? true : foundMatch
// default: continue
// }
}

return false
}

private func asyncIndex() {
let startTime = Date()
guard let indexer = indexer else {
return
}

let asyncController = SearchIndexer.AsyncManager(index: indexer)

Task{
var textFiles = [SearchIndexer.AsyncManager.TextFile]()
for file in files {
textFiles.append(SearchIndexer.AsyncManager.TextFile(url: file.url, text: file.content ?? ""))
}

let _ = await asyncController.addText(files: textFiles, flushWhenComplete: false)

indexer.flush()

print("Added: \(indexer.documents().count) documents to the index.")

let endTime = Date()
elapsedTime = endTime.timeIntervalSince(startTime)
print("Elapsed time: \(elapsedTime ?? 0.0)")
}
}

private func index() {
let startTime = Date()
guard let indexer = indexer else {
return
}

files.forEach { file in
_ = indexer.add(file.url, text: file.content!, canReplace: false)
}

indexer.flush()

print(indexer.documents())

let endTime = Date()
elapsedTime = endTime.timeIntervalSince(startTime)
}

func addFilesWithURL() {
let openPanel = NSOpenPanel()
openPanel.canChooseFiles = false
openPanel.canChooseDirectories = true
openPanel.allowsMultipleSelection = false

if openPanel.runModal() == .OK {
if let selectedFolderURL = openPanel.url {
let fileManager = FileManager.default

if let enumerator = fileManager.enumerator(at: selectedFolderURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles], errorHandler: nil) {
for case let fileURL as URL in enumerator {
files.append(FileViewModel(name: fileURL.lastPathComponent, url: fileURL))
}
}
}
}
}

func addFilesWithContentText() {
let openPanel = NSOpenPanel()
openPanel.canChooseFiles = false
openPanel.canChooseDirectories = true
openPanel.allowsMultipleSelection = false

if openPanel.runModal() == .OK {
if let selectedFolderURL = openPanel.url {
let fileManager = FileManager.default

if let enumerator = fileManager.enumerator(at: selectedFolderURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles], errorHandler: nil) {
for case let fileURL as URL in enumerator {
var isDirec: ObjCBool = false
fileManager.fileExists(atPath: fileURL.path, isDirectory: &isDirec)

guard isDirec.boolValue == false else {
continue
}
guard let fileContent = try? String(contentsOf: fileURL) else {
continue
}

files.append(FileViewModel(name: fileURL.lastPathComponent, url: fileURL, content: fileContent))
}
}
}
}

print("Added Files")

}
}


#Preview {
ContentView()
}


extension String {
func character(at index: Int) -> Character {
return self[self.index(self.startIndex, offsetBy: index)]
}

func appearancesOfSubstring(substring: String, toLeft: Int=0, toRight: Int=0) -> [Range<String.Index>] {
guard !substring.isEmpty && self.contains(substring) else { return [] }
var appearances: [Range<String.Index>] = []
for (index, character) in self.enumerated() where character == substring.first {
let startOfFoundCharacter = self.index(self.startIndex, offsetBy: index)
guard index + substring.count < self.count else { continue }
let lengthOfFoundCharacter = self.index(self.startIndex, offsetBy: (substring.count + index))
if self[startOfFoundCharacter..<lengthOfFoundCharacter] == substring {
let startIndex = self.index(
self.startIndex,
offsetBy: index - (toLeft <= index ? toLeft : 0)
)
let endIndex = self.index(
self.startIndex,
offsetBy: substring.count + index + (substring.count+index+toRight <= self.count ? toRight : 0)
)
appearances.append(startIndex..<endIndex)
}
}
return appearances
}
}
3 changes: 0 additions & 3 deletions SearchKitDemo/SearchKitDemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ import SwiftUI
@main
struct SearchKitDemoApp: App {
var body: some Scene {
// DocumentGroup(newDocument: SearchKitDemoDocument()) { file in
// ContentView(document: file.$document)
// }
WindowGroup {
ContentView()
}
Expand Down
Loading

0 comments on commit d4d9055

Please sign in to comment.