Last active
March 19, 2026 16:23
-
-
Save beeksiwaais/0555d0e0ae42c1b07e27478294d0561f to your computer and use it in GitHub Desktop.
A script that start a webserver with the specified directory as its root. The script will also index the content of some text files and pdf in a index.webstart file
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // MARK: - main.swift | |
| import Foundation | |
| import PDFKit // Import PDFKit for native PDF processing | |
| import Network // Import Network framework for web server capabilities | |
| import AppKit // Import AppKit for GUI elements (NSWindow, NSTextView, NSButton) | |
| import UniformTypeIdentifiers // For UTType and MIME type mapping | |
| // Define a custom error type for better error handling | |
| enum IndexerError: Error, CustomStringConvertible { | |
| case invalidArguments | |
| case directoryNotFound(String) | |
| case cacheLoadFailed(Error) | |
| case cacheSaveFailed(Error) | |
| case fileReadError(String, Error) | |
| case pdfProcessingError(String, Error?) | |
| case webServerStartFailed(Error) | |
| case windowModeRequiresMacOS | |
| case pathTraversalAttempt(String) | |
| case fileServeError(String, Error?) | |
| case invalidAPIPath(String) | |
| var description: String { | |
| switch self { | |
| case .invalidArguments: | |
| return "Invalid arguments. Usage: fulltextindexer <directory_to_index> [--window-mode] [cache_file_path.webstart]" | |
| case .directoryNotFound(let path): | |
| return "Error: Directory not found at path: \(path)" | |
| case .cacheLoadFailed(let error): | |
| return "Error loading cache: \(error.localizedDescription)" | |
| case .cacheSaveFailed(let error): | |
| return "Error saving cache: \(error.localizedDescription)" | |
| case .fileReadError(let path, let error): | |
| return "Error reading file '\(path)': \(error.localizedDescription)" | |
| case .pdfProcessingError(let path, let error): | |
| return "Error processing PDF '\(path)': \(error?.localizedDescription ?? "Unknown error")" | |
| case .webServerStartFailed(let error): | |
| return "Error starting web server: \(error.localizedDescription)" | |
| case .windowModeRequiresMacOS: | |
| return "Window mode is only supported on macOS." | |
| case .pathTraversalAttempt(let path): | |
| return "Security Error: Path traversal attempt detected: \(path)" | |
| case .fileServeError(let path, let error): | |
| return "Error serving file '\(path)': \(error?.localizedDescription ?? "Unknown error")" | |
| case .invalidAPIPath(let path): | |
| return "Invalid API path requested: \(path)" | |
| } | |
| } | |
| } | |
| // MARK: - IndexData.swift (Data structure for caching) | |
| /// Represents a location where a term was found in a document. | |
| struct DocumentLocation: Codable, Hashable { | |
| let documentID: String | |
| let lineNumber: Int // Line number where the term was found (1-based) | |
| } | |
| // This struct holds the entire index and document path mapping, | |
| // making it easy to encode and decode for caching. | |
| struct IndexData: Codable { | |
| var index: [String: Set<DocumentLocation>] // Word -> Set of DocumentLocation | |
| var documentPaths: [String: String] // Document ID -> File Path | |
| init(index: [String: Set<DocumentLocation>] = [:], documentPaths: [String: String] = [:]) { | |
| self.index = index | |
| self.documentPaths = documentPaths | |
| } | |
| } | |
| // MARK: - IndexManager.swift (Core logic for indexing and caching) | |
| class IndexManager { | |
| // Using a DispatchQueue for thread-safe access to mutable properties | |
| private let queue = DispatchQueue(label: "com.fulltextindexer.indexmanager", attributes: .concurrent) | |
| private var _index: [String: Set<DocumentLocation>] = [:] | |
| private var _documentPaths: [String: String] = [:] | |
| private var _documentIdCounter: Int = 0 | |
| private let rootDirectory: URL // The root directory being indexed | |
| // Thread-safe computed properties for index and documentPaths | |
| var index: [String: Set<DocumentLocation>] { | |
| queue.sync { _index } | |
| } | |
| var documentPaths: [String: String] { | |
| queue.sync { _documentPaths } | |
| } | |
| init(rootDirectory: URL) { | |
| self.rootDirectory = rootDirectory | |
| } | |
| // MARK: - Indexing Logic | |
| /// Indexes all supported text files within a given directory recursively. | |
| /// - Parameter directoryPath: The path to the directory to index. | |
| func indexDirectory(atPath directoryPath: String) throws { | |
| let fileManager = FileManager.default | |
| guard let enumerator = fileManager.enumerator(atPath: directoryPath) else { | |
| throw IndexerError.directoryNotFound(directoryPath) | |
| } | |
| print("Starting indexing of directory: \(directoryPath)") | |
| for case let filePath as String in enumerator { | |
| let fullPath = (directoryPath as NSString).appendingPathComponent(filePath) | |
| let fileURL = URL(fileURLWithPath: fullPath) | |
| var isDirectory: ObjCBool = false | |
| guard fileManager.fileExists(atPath: fullPath, isDirectory: &isDirectory) && !isDirectory.boolValue else { | |
| continue // Skip directories | |
| } | |
| do { | |
| try processFile(at: fileURL) | |
| } catch let error as IndexerError { | |
| print(error.description) // Print specific indexer errors | |
| } catch { | |
| print("An unexpected error occurred while processing \(fileURL.lastPathComponent): \(error.localizedDescription)") | |
| } | |
| } | |
| print("Indexing complete. Indexed \(documentPaths.count) documents.") | |
| } | |
| /// Processes a single file, extracts its text, and adds it to the index. | |
| /// - Parameter fileURL: The URL of the file to process. | |
| private func processFile(at fileURL: URL) throws { | |
| let fileExtension = fileURL.pathExtension.lowercased() | |
| var extractedLines: [String]? | |
| switch fileExtension { | |
| case "txt", "md", "html": | |
| extractedLines = try extractPlainTextLines(from: fileURL) | |
| case "rtf": | |
| extractedLines = try extractRTFLines(from: fileURL) | |
| case "pdf": | |
| // For PDFs, we get the full text but cannot easily get line numbers. | |
| // So, for PDFs, we'll index at the document level (line 0 or 1 for simplicity). | |
| do { | |
| let text = try extractPDF(from: fileURL) | |
| extractedLines = text.components(separatedBy: .newlines).filter { !$0.isEmpty } | |
| } catch { | |
| throw IndexerError.pdfProcessingError(fileURL.path, error) | |
| } | |
| default: | |
| return // Skip unsupported file types silently | |
| } | |
| guard let lines = extractedLines, !lines.isEmpty else { | |
| return // No text extracted or empty text | |
| } | |
| // Use a barrier to ensure exclusive write access | |
| queue.sync(flags: .barrier) { | |
| let documentID = "doc_\(_documentIdCounter)" | |
| _documentIdCounter += 1 | |
| _documentPaths[documentID] = fileURL.path | |
| for (lineNumber, line) in lines.enumerated() { | |
| let words = tokenize(text: line) | |
| for word in words { | |
| let location = DocumentLocation(documentID: documentID, lineNumber: lineNumber + 1) // 1-based line numbers | |
| _index[word, default: []].insert(location) | |
| } | |
| } | |
| } | |
| print("Indexed: \(fileURL.lastPathComponent)") | |
| } | |
| /// Extracts plain text content line by line from a file. | |
| /// Supports .txt, .md, .html | |
| /// - Parameter fileURL: The URL of the text file. | |
| /// - Returns: An array of strings, each representing a line. | |
| private func extractPlainTextLines(from fileURL: URL) throws -> [String] { | |
| do { | |
| let content = try String(contentsOf: fileURL, encoding: .utf8) | |
| return content.components(separatedBy: .newlines) | |
| } catch { | |
| throw IndexerError.fileReadError(fileURL.path, error) | |
| } | |
| } | |
| /// Extracts text content line by line from an RTF file. | |
| /// - Parameter fileURL: The URL of the RTF file. | |
| /// - Returns: An array of strings, each representing a line. | |
| private func extractRTFLines(from fileURL: URL) throws -> [String] { | |
| do { | |
| let rtfData = try Data(contentsOf: fileURL) | |
| let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ | |
| .documentType: NSAttributedString.DocumentType.rtf | |
| ] | |
| let attributedString = try NSAttributedString(data: rtfData, options: options, documentAttributes: nil) | |
| return attributedString.string.components(separatedBy: .newlines) | |
| } catch { | |
| throw IndexerError.fileReadError(fileURL.path, error) | |
| } | |
| } | |
| /// Extracts text content from a PDF file using macOS's PDFKit framework. | |
| /// Note: Does not provide line-level granularity. | |
| /// - Parameter fileURL: The URL of the PDF file. | |
| /// - Returns: The extracted text content. | |
| private func extractPDF(from fileURL: URL) throws -> String { | |
| guard let pdfDocument = PDFDocument(url: fileURL) else { | |
| throw IndexerError.pdfProcessingError(fileURL.path, nil) | |
| } | |
| var fullText = "" | |
| for i in 0..<pdfDocument.pageCount { | |
| if let page = pdfDocument.page(at: i) { | |
| if let pageText = page.string { | |
| fullText += pageText | |
| } | |
| } | |
| } | |
| return fullText | |
| } | |
| /// Converts raw text into an array of lowercase words. | |
| /// - Parameter text: The input text. | |
| /// - Returns: An array of tokenized words. | |
| private func tokenize(text: String) -> [String] { | |
| return text.lowercased() | |
| .components(separatedBy: CharacterSet.alphanumerics.inverted) | |
| .filter { !$0.isEmpty } | |
| } | |
| // MARK: - Cache Management | |
| /// Saves the current index and document paths to a specified file using PropertyListEncoder. | |
| /// - Parameter cacheFilePath: The absolute path where the cache should be saved. | |
| func saveCache(to cacheFilePath: String) throws { | |
| let indexData = IndexData(index: index, documentPaths: documentPaths) // Access thread-safe properties | |
| do { | |
| let encodedData = try PropertyListEncoder().encode(indexData) | |
| let archivedData = try NSKeyedArchiver.archivedData(withRootObject: encodedData, requiringSecureCoding: true) | |
| try archivedData.write(to: URL(fileURLWithPath: cacheFilePath)) | |
| print("Cache saved successfully to: \(cacheFilePath)") | |
| } catch { | |
| throw IndexerError.cacheSaveFailed(error) | |
| } | |
| } | |
| /// Loads the index and document paths from a specified cache file. | |
| /// - Parameter cacheFilePath: The absolute path to the cache file. | |
| /// - Returns: True if cache was loaded successfully, false otherwise. | |
| func loadCache(from cacheFilePath: String) throws -> Bool { | |
| let fileManager = FileManager.default | |
| guard fileManager.fileExists(atPath: cacheFilePath) else { | |
| print("Cache file not found at: \(cacheFilePath). Starting fresh indexing.") | |
| return false | |
| } | |
| do { | |
| let archivedData = try Data(contentsOf: URL(fileURLWithPath: cacheFilePath)) | |
| // Define allowed classes for secure unarchiving | |
| let allowedClasses = [ | |
| NSData.self, | |
| ] | |
| guard let decodedArchivedData = try NSKeyedUnarchiver.unarchivedObject(ofClasses: allowedClasses, from: archivedData) as? Data else { | |
| throw IndexerError.cacheLoadFailed(NSError(domain: "IndexManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to unarchive data or incorrect class type."])) | |
| } | |
| let loadedIndexData = try PropertyListDecoder().decode(IndexData.self, from: decodedArchivedData) | |
| // Update internal state using barrier for thread safety | |
| queue.sync(flags: .barrier) { | |
| self._index = loadedIndexData.index | |
| self._documentPaths = loadedIndexData.documentPaths | |
| self._documentIdCounter = (loadedIndexData.documentPaths.keys.compactMap { Int($0.replacingOccurrences(of: "doc_", with: "")) }.max() ?? -1) + 1 | |
| } | |
| print("Cache loaded successfully from: \(cacheFilePath). Indexed \(documentPaths.count) documents.") | |
| return true | |
| } catch { | |
| throw IndexerError.cacheLoadFailed(error) | |
| } | |
| } | |
| // MARK: - Search Functionality | |
| /// Performs a simple full-text search on the indexed data. | |
| /// - Parameter searchTerm: The word to be searched for. | |
| /// - Returns: A dictionary mapping file paths to an array of line numbers where the term was found. | |
| /// For PDF files, line numbers will be 1 (or -1 if not found on any specific line). | |
| func search(for searchTerm: String) -> [String: Set<Int>] { | |
| let normalizedSearchTerm = searchTerm.lowercased() | |
| // Access thread-safe index | |
| guard let docLocations = index[normalizedSearchTerm] else { | |
| return [:] // Term not found | |
| } | |
| var results: [String: Set<Int>] = [:] | |
| // Access thread-safe documentPaths | |
| for docLocation in docLocations { | |
| if let path = documentPaths[docLocation.documentID] { | |
| results[path, default: []].insert(docLocation.lineNumber) | |
| } | |
| } | |
| return results | |
| } | |
| } | |
| // MARK: - SimpleWebServer.swift (Basic HTTP Server) | |
| class SimpleWebServer { | |
| private let port: UInt16 | |
| private var listener: NWListener? | |
| private let indexManager: IndexManager | |
| private let rootDirectory: URL // The root directory to serve files from | |
| private let isIndexingComplete: () -> Bool // Closure to check indexing status | |
| init(port: UInt16, indexManager: IndexManager, rootDirectory: URL, isIndexingComplete: @escaping () -> Bool) { | |
| self.port = port | |
| self.indexManager = indexManager | |
| self.rootDirectory = rootDirectory | |
| self.isIndexingComplete = isIndexingComplete | |
| } | |
| func start() throws { | |
| let parameters = NWParameters.tcp | |
| parameters.allowLocalEndpointReuse = true // Allow reuse of the port | |
| listener = try NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port)!) | |
| listener?.stateUpdateHandler = { newState in | |
| switch newState { | |
| case .ready: | |
| print("Server listening on http://localhost:\(self.port)") | |
| case .failed(let error): | |
| print("Server failed with error: \(error)") | |
| exit(1) | |
| default: | |
| break | |
| } | |
| } | |
| listener?.newConnectionHandler = { newConnection in | |
| // Start connection on a new queue to avoid blocking the listener's queue | |
| newConnection.start(queue: .global(qos: .userInitiated)) | |
| self.handleConnection(newConnection) | |
| } | |
| // Start listener on the main queue to satisfy the "main thread" requirement for the server | |
| listener?.start(queue: .main) | |
| } | |
| func stop() { | |
| listener?.cancel() | |
| print("Web server stopped.") | |
| } | |
| private func handleConnection(_ connection: NWConnection) { | |
| connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { (data, context, isComplete, error) in | |
| if let data = data, !data.isEmpty { | |
| let requestString = String(data: data, encoding: .utf8) ?? "" | |
| self.processRequest(requestString, connection: connection) | |
| } | |
| if let error = error { | |
| print("Connection error: \(error)") | |
| } | |
| // Continue receiving until the request is complete | |
| if !isComplete { | |
| self.handleConnection(connection) | |
| } | |
| } | |
| } | |
| private func processRequest(_ requestString: String, connection: NWConnection) { | |
| let lines = requestString.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: true) | |
| guard let requestLine = lines.first?.trimmingCharacters(in: .whitespacesAndNewlines) else { | |
| self.sendResponse(connection, statusCode: "400 Bad Request", headers: nil, body: "Bad Request", contentType: "text/plain") | |
| return | |
| } | |
| let components = requestLine.split(separator: " ") | |
| guard components.count >= 2 else { | |
| self.sendResponse(connection, statusCode: "400 Bad Request", headers: nil, body: "Bad Request", contentType: "text/plain") | |
| return | |
| } | |
| let method = String(components[0]) | |
| let path = String(components[1]) | |
| let urlComponents = URLComponents(string: path) | |
| let requestPath = urlComponents?.path ?? "/" | |
| let queryItems = urlComponents?.queryItems | |
| if requestPath == "/" && method == "GET" { | |
| self.sendHomePage(connection) | |
| } else if requestPath == "/api/browse" && method == "GET" { | |
| let requestedPath = queryItems?.first(where: { $0.name == "path" })?.value ?? "" | |
| self.handleApiBrowseRequest(connection, relativePath: requestedPath) | |
| } else if requestPath.hasPrefix("/browse/") && method == "GET" { | |
| let relativePath = String(requestPath.dropFirst("/browse/".count)) | |
| self.handleFileServeRequest(connection, relativePath: relativePath) | |
| } else if requestPath == "/search" && method == "GET" { | |
| if let searchTerm = queryItems?.first(where: { $0.name == "term" })?.value { | |
| self.sendSearchResults(connection, searchTerm: searchTerm) | |
| } else { | |
| self.sendResponse(connection, statusCode: "400 Bad Request", headers: nil, body: "Missing 'term' parameter for search.", contentType: "text/plain") | |
| } | |
| } | |
| else { | |
| self.sendResponse(connection, statusCode: "404 Not Found", headers: nil, body: "Not Found", contentType: "text/plain") | |
| } | |
| } | |
| // New API endpoint to serve directory contents as JSON | |
| private func handleApiBrowseRequest(_ connection: NWConnection, relativePath: String) { | |
| // Prevent path traversal | |
| guard !relativePath.contains("..") else { | |
| self.sendResponse(connection, statusCode: "403 Forbidden", headers: nil, body: IndexerError.pathTraversalAttempt(relativePath).description, contentType: "text/plain") | |
| return | |
| } | |
| let absolutePath = rootDirectory.appendingPathComponent(relativePath).path | |
| var isDirectory: ObjCBool = false | |
| let fileManager = FileManager.default | |
| guard fileManager.fileExists(atPath: absolutePath, isDirectory: &isDirectory) && isDirectory.boolValue else { | |
| self.sendResponse(connection, statusCode: "404 Not Found", headers: nil, body: "Directory not found or not a directory: \(relativePath)", contentType: "text/plain") | |
| return | |
| } | |
| do { | |
| let contents = try fileManager.contentsOfDirectory(atPath: absolutePath) | |
| var items: [[String: Any]] = [] | |
| for item in contents.sorted() { | |
| let itemAbsolutePath = (absolutePath as NSString).appendingPathComponent(item) | |
| let itemRelativePath = (relativePath as NSString).appendingPathComponent(item) | |
| var isItemDirectory: ObjCBool = false | |
| _ = fileManager.fileExists(atPath: itemAbsolutePath, isDirectory: &isItemDirectory) | |
| items.append([ | |
| "name": item, | |
| "type": isItemDirectory.boolValue ? "directory" : "file", | |
| "relativePath": itemRelativePath // Path relative to the root for client-side use | |
| ]) | |
| } | |
| let jsonResponse: [String: Any] = ["currentPath": relativePath, "items": items] | |
| let jsonData = try JSONSerialization.data(withJSONObject: jsonResponse, options: []) | |
| if let jsonString = String(data: jsonData, encoding: .utf8) { | |
| self.sendResponse(connection, statusCode: "200 OK", headers: nil, body: jsonString, contentType: "application/json") | |
| } else { | |
| self.sendResponse(connection, statusCode: "500 Internal Server Error", headers: nil, body: "Failed to create JSON string", contentType: "text/plain") | |
| } | |
| } catch { | |
| self.sendResponse(connection, statusCode: "500 Internal Server Error", headers: nil, body: IndexerError.fileServeError(relativePath, error).description, contentType: "text/plain") | |
| } | |
| } | |
| // Handles direct file serving for /browse/ paths | |
| private func handleFileServeRequest(_ connection: NWConnection, relativePath: String) { | |
| // Prevent path traversal | |
| guard !relativePath.contains("..") else { | |
| self.sendResponse(connection, statusCode: "403 Forbidden",headers: nil, body: IndexerError.pathTraversalAttempt(relativePath).description, contentType: "text/plain") | |
| return | |
| } | |
| let absolutePath = rootDirectory.appendingPathComponent(relativePath).path | |
| var isDirectory: ObjCBool = false | |
| let fileManager = FileManager.default | |
| guard fileManager.fileExists(atPath: absolutePath, isDirectory: &isDirectory) else { | |
| self.sendResponse(connection, statusCode: "404 Not Found", headers: nil, body: "File or directory not found: \(relativePath)", contentType: "text/plain") | |
| return | |
| } | |
| if isDirectory.boolValue { | |
| // If it's a directory, redirect to the main page with the browse path | |
| // This ensures the SPA logic takes over for directory browsing | |
| self.sendResponse(connection, statusCode: "302 Found", headers: ["Location": "/?path=\(relativePath)"], body: "Redirecting to /?path=\(relativePath)", contentType: "text/plain") | |
| } else { | |
| // It's a file, serve its content | |
| do { | |
| let fileData = try Data(contentsOf: URL(fileURLWithPath: absolutePath)) | |
| let mimeType = getMimeType(forPath: absolutePath) | |
| self.sendResponse(connection, statusCode: "200 OK", bodyData: fileData, contentType: mimeType) | |
| } catch { | |
| self.sendResponse(connection, statusCode: "500 Internal Server Error", headers: nil, body: IndexerError.fileServeError(relativePath, error).description, contentType: "text/plain") | |
| } | |
| } | |
| } | |
| // Helper to determine MIME type for a given file path | |
| private func getMimeType(forPath path: String) -> String { | |
| let url = URL(fileURLWithPath: path) | |
| if let type = UTType(filenameExtension: url.pathExtension) { | |
| if let mimeType = type.preferredMIMEType { | |
| return mimeType | |
| } | |
| } | |
| return "application/octet-stream" // Default for unknown types | |
| } | |
| private func sendHomePage(_ connection: NWConnection) { | |
| // Inject the root directory path into the JavaScript for client-side use | |
| let rootDirPathForJS = rootDirectory.path.replacingOccurrences(of: "\\", with: "\\\\") // Escape backslashes if any | |
| let indexingStatusMessage = self.isIndexingComplete() ? "Indexing complete." : "Indexing in progress..." | |
| let html = """ | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Full-Text Indexer</title> | |
| <style> | |
| body { font-family: 'Inter', sans-serif; margin: 0; padding: 20px; background-color: #f0f2f5; color: #333; display: flex; justify-content: center; align-items: flex-start; min-height: 100vh; } | |
| .container { width: 100%; max-width: 800px; background: #ffffff; padding: 30px; border-radius: 12px; box-shadow: 0 6px 20px rgba(0,0,0,0.08); } | |
| h1 { color: #2c3e50; text-align: center; margin-bottom: 25px; } | |
| #search-container { margin-bottom: 25px; display: flex; flex-direction: column; gap: 10px; } | |
| #search-input { width: 100%; padding: 12px 15px; border: 1px solid #dcdcdc; border-radius: 8px; font-size: 16px; box-sizing: border-box; transition: border-color 0.3s ease; } | |
| #search-input:focus { border-color: #007bff; outline: none; box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25); } | |
| #status { text-align: center; font-style: italic; color: #666; font-size: 14px; margin-top: 10px; } | |
| .section-toggle-buttons { display: flex; justify-content: center; margin-bottom: 20px; gap: 10px; } | |
| .section-toggle-buttons button { padding: 10px 20px; border: 1px solid #007bff; border-radius: 8px; background-color: #ffffff; color: #007bff; cursor: pointer; font-size: 16px; transition: all 0.3s ease; } | |
| .section-toggle-buttons button.active { background-color: #007bff; color: #ffffff; } | |
| .section-toggle-buttons button:hover:not(.active) { background-color: #e6f2ff; } | |
| .list-section { margin-top: 20px; } | |
| .list-section h2 { color: #34495e; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 10px; display: flex; align-items: center; justify-content: space-between; } | |
| .list-section h2 button { background-color: #28a745; color: white; border: none; padding: 8px 15px; border-radius: 5px; cursor: pointer; font-size: 14px; transition: background-color 0.3s ease; } | |
| .list-section h2 button:hover { background-color: #218838; } | |
| ul { list-style: none; padding: 0; margin: 0; } | |
| li { background: #f8f9fa; margin-bottom: 10px; padding: 12px 15px; border-radius: 8px; border: 1px solid #e9ecef; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; } | |
| li a { text-decoration: none; color: #007bff; font-weight: 500; flex-grow: 1; display: block; } | |
| li a:hover { text-decoration: underline; color: #0056b3; } | |
| li .controls { display: flex; gap: 8px; margin-left: 10px; } | |
| li .controls button { background-color: #6c757d; color: white; border: none; padding: 6px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; transition: background-color 0.3s ease; } | |
| li .controls button.delete { background-color: #dc3545; } | |
| li .controls button:hover { opacity: 0.9; } | |
| li .controls button.up-down { background-color: #007bff; } | |
| li .controls button.edit-text { background-color: #ffc107; color: #333; } | |
| .view-item-text-editor { width: 100%; margin-top: 10px; padding: 10px; border: 1px solid #ccc; border-radius: 5px; min-height: 80px; font-size: 14px; box-sizing: border-box; } | |
| #add-new-view-btn, #add-text-section-btn, #save-view-changes-btn, #delete-current-view-btn { | |
| margin-top: 15px; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; transition: background-color 0.3s ease; | |
| } | |
| #add-new-view-btn { background-color: #007bff; color: white; } | |
| #add-new-view-btn:hover { background-color: #0056b3; } | |
| #add-text-section-btn { background-color: #17a2b8; color: white; } | |
| #add-text-section-btn:hover { background-color: #138496; } | |
| #save-view-changes-btn { background-color: #28a745; color: white; } | |
| #save-view-changes-btn:hover { background-color: #218838; } | |
| #delete-current-view-btn { background-color: #dc3545; color: white; } | |
| #delete-current-view-btn:hover { background-color: #c82333; } | |
| .file-path { flex-grow: 1; } | |
| .line-numbers { font-size: 0.9em; color: #666; margin-left: 15px; } | |
| .current-browse-path { font-size: 0.9em; color: #555; margin-bottom: 10px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Document Indexer</h1> | |
| <div id="search-container"> | |
| <input type="text" id="search-input" placeholder="Type to search files..."> | |
| <div id="status">\(indexingStatusMessage)</div> | |
| </div> | |
| <div class="section-toggle-buttons"> | |
| <button id="show-browse-btn" class="active">Browse All Content</button> | |
| <button id="show-search-results-toggle-btn" style="display:none;">Search Results</button> | |
| <button id="show-views-btn">My Views</button> | |
| </div> | |
| <div class="list-section" id="browse-content-section"> | |
| <h2>Browse Content</h2> | |
| <div class="current-browse-path">Current Path: <span id="current-path-display">/</span></div> | |
| <ul id="browse-list"></ul> | |
| </div> | |
| <div class="list-section" id="search-results-section" style="display: none;"> | |
| <h2>Search Results <button id="save-search-as-view-btn">Save as View</button></h2> | |
| <ul id="search-results"></ul> | |
| </div> | |
| <div class="list-section" id="views-list-section" style="display: none;"> | |
| <h2>My Views</h2> | |
| <ul id="views-list"></ul> | |
| <button id="add-new-view-btn">Create Empty View</button> | |
| </div> | |
| <div class="list-section" id="view-editor-section" style="display: none;"> | |
| <h2 id="view-editor-title"></h2> | |
| <div id="view-items-container"> | |
| <ul id="view-items-list"></ul> | |
| <button id="add-text-section-btn">Add Text Section</button> | |
| <button id="save-view-changes-btn">Save View</button> | |
| <button id="delete-current-view-btn">Delete View</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // Injected from Swift backend | |
| const rootDirectoryPath = "\(rootDirPathForJS)"; | |
| // --- Data Structures for Views (Client-Side) --- | |
| class ViewItem { | |
| constructor(type, content, id = crypto.randomUUID()) { | |
| this.id = id; // Unique ID for each item within a view | |
| this.type = type; // 'file' or 'text' | |
| this.content = content; // File path or text content (relative path for files) | |
| } | |
| } | |
| class UserView { | |
| constructor(name, items = [], id = crypto.randomUUID()) { | |
| this.id = id; // Unique ID for the view itself | |
| this.name = name; | |
| this.items = items; | |
| } | |
| } | |
| // --- Local Storage Management for Views --- | |
| const LOCAL_STORAGE_VIEWS_KEY = 'fullTextIndexerViews'; | |
| function loadViewsFromLocalStorage() { | |
| try { | |
| const jsonString = localStorage.getItem(LOCAL_STORAGE_VIEWS_KEY); | |
| if (jsonString) { | |
| const rawViews = JSON.parse(jsonString); | |
| // Reconstruct UserView and ViewItem objects from raw data | |
| return rawViews.map(rawView => { | |
| const items = rawView.items.map(rawItem => new ViewItem(rawItem.type, rawItem.content, rawItem.id)); | |
| return new UserView(rawView.name, items, rawView.id); | |
| }); | |
| } | |
| } catch (e) { | |
| console.error("Error loading views from local storage:", e); | |
| } | |
| return []; | |
| } | |
| function saveViewsToLocalStorage(views) { | |
| try { | |
| localStorage.setItem(LOCAL_STORAGE_VIEWS_KEY, JSON.stringify(views)); | |
| } catch (e) { | |
| console.error("Error saving views to local storage:", e); | |
| alert("Error saving views to local storage. Your browser's storage might be full or blocked."); | |
| } | |
| } | |
| function createViewInLocalStorage(name, filePathsWithLines) { | |
| // filePathsWithLines is { filePath: [lineNumbers] } | |
| const items = Object.entries(filePathsWithLines).map(([path, lines]) => { | |
| // For views, we just store the file path. Line numbers are search-specific. | |
| return new ViewItem('file', path); | |
| }); | |
| const newView = new UserView(name, items); | |
| const views = loadViewsFromLocalStorage(); | |
| views.push(newView); | |
| saveViewsToLocalStorage(views); | |
| return newView; | |
| } | |
| function updateViewInLocalStorage(updatedView) { | |
| let views = loadViewsFromLocalStorage(); | |
| const index = views.findIndex(v => v.id === updatedView.id); | |
| if (index !== -1) { | |
| views[index] = updatedView; | |
| saveViewsToLocalStorage(views); | |
| return true; | |
| } | |
| return false; | |
| } | |
| function deleteViewFromLocalStorage(viewId) { | |
| let views = loadViewsFromLocalStorage(); | |
| const initialLength = views.length; | |
| views = views.filter(v => v.id !== viewId); | |
| if (views.length < initialLength) { | |
| saveViewsToLocalStorage(views); | |
| return true; | |
| } | |
| return false; | |
| } | |
| // --- UI Elements --- | |
| const searchInput = document.getElementById('search-input'); | |
| const browseContentSection = document.getElementById('browse-content-section'); | |
| const browseList = document.getElementById('browse-list'); | |
| const currentPathDisplay = document.getElementById('current-path-display'); | |
| const searchResultsSection = document.getElementById('search-results-section'); | |
| const searchResultsList = document.getElementById('search-results'); | |
| const statusDiv = document.getElementById('status'); | |
| const saveSearchAsViewBtn = document.getElementById('save-search-as-view-btn'); | |
| const showBrowseBtn = document.getElementById('show-browse-btn'); | |
| const showSearchResultsToggleBtn = document.getElementById('show-search-results-toggle-btn'); | |
| const showViewsBtn = document.getElementById('show-views-btn'); | |
| const viewsListSection = document.getElementById('views-list-section'); | |
| const viewsList = document.getElementById('views-list'); | |
| const addNewViewBtn = document.getElementById('add-new-view-btn'); | |
| const viewEditorSection = document.getElementById('view-editor-section'); | |
| const viewEditorTitle = document.getElementById('view-editor-title'); | |
| const viewItemsList = document.getElementById('view-items-list'); | |
| const addTextSectionBtn = document.getElementById('add-text-section-btn'); | |
| const saveViewChangesBtn = document.getElementById('save-view-changes-btn'); | |
| const deleteCurrentViewBtn = document.getElementById('delete-current-view-btn'); | |
| let currentSearchResults = {}; // Stores {filePath: [lineNumbers]} | |
| let currentView = null; // Stores the view currently being edited (UserView object) | |
| let currentBrowsePath = ''; // Stores the currently browsed relative path | |
| // Initial status from server (indexing might still be in progress) | |
| let isIndexingDone = \(self.isIndexingComplete() ? "true" : "false"); | |
| if (isIndexingDone) { | |
| statusDiv.textContent = 'Indexing complete.'; | |
| } else { | |
| statusDiv.textContent = 'Indexing in progress... (Results may be partial)'; | |
| } | |
| // --- Section Toggling --- | |
| function showSection(sectionId) { | |
| browseContentSection.style.display = 'none'; | |
| searchResultsSection.style.display = 'none'; | |
| viewsListSection.style.display = 'none'; | |
| viewEditorSection.style.display = 'none'; | |
| document.querySelectorAll('.section-toggle-buttons button').forEach(btn => btn.classList.remove('active')); | |
| switch (sectionId) { | |
| case 'browse': | |
| browseContentSection.style.display = 'block'; | |
| showBrowseBtn.classList.add('active'); | |
| break; | |
| case 'search-results': | |
| searchResultsSection.style.display = 'block'; | |
| showSearchResultsToggleBtn.classList.add('active'); | |
| break; | |
| case 'views': | |
| viewsListSection.style.display = 'block'; | |
| showViewsBtn.classList.add('active'); | |
| break; | |
| case 'view-editor': | |
| viewEditorSection.style.display = 'block'; | |
| break; | |
| } | |
| } | |
| // --- Browse Content Functionality --- | |
| async function fetchDirectoryContents(path) { | |
| browseList.innerHTML = '<li>Loading directory...</li>'; | |
| try { | |
| const response = await fetch(`/api/browse?path=${encodeURIComponent(path)}`); | |
| const data = await response.json(); | |
| browseList.innerHTML = ''; // Clear previous contents | |
| currentBrowsePath = data.currentPath; | |
| currentPathDisplay.textContent = `/${currentBrowsePath}`; | |
| // Add ".." link if not at root | |
| if (currentBrowsePath !== '' && currentBrowsePath !== '/') { | |
| const parentPath = currentBrowsePath.split('/').slice(0, -1).join('/'); | |
| const li = document.createElement('li'); | |
| li.innerHTML = `📁 <a href="#" data-path="${parentPath}" class="browse-link">.. (Parent Directory)</a>`; | |
| browseList.appendChild(li); | |
| } | |
| if (data.items && data.items.length > 0) { | |
| data.items.forEach(item => { | |
| const li = document.createElement('li'); | |
| const icon = item.type === 'directory' ? '📁' : '📄'; | |
| const linkHref = item.type === 'directory' ? '#' : `/browse/${encodeURIComponent(item.relativePath)}`; | |
| const dataPathAttr = item.type === 'directory' ? `data-path="${item.relativePath}"` : ''; | |
| li.innerHTML = `${icon} <a href="${linkHref}" ${dataPathAttr} class="browse-link">${item.name}</a>`; | |
| browseList.appendChild(li); | |
| }); | |
| } else { | |
| browseList.innerHTML = '<li>This directory is empty.</li>'; | |
| } | |
| // Add event listeners for new browse links | |
| document.querySelectorAll('.browse-link').forEach(link => { | |
| if (link.dataset.path) { // Only for directories | |
| link.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| const newPath = e.target.dataset.path; | |
| history.pushState({ path: newPath }, '', `/?path=${encodeURIComponent(newPath)}`); | |
| fetchDirectoryContents(newPath); | |
| }); | |
| } | |
| }); | |
| } catch (error) { | |
| console.error('Error fetching directory contents:', error); | |
| browseList.innerHTML = '<li>Error loading directory contents.</li>'; | |
| } | |
| } | |
| // Handle browser's back/forward buttons | |
| window.addEventListener('popstate', (event) => { | |
| const path = event.state ? event.state.path : ''; | |
| fetchDirectoryContents(path); | |
| }); | |
| // Initial load: determine path from URL or default to root | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const initialPath = urlParams.get('path') || ''; | |
| fetchDirectoryContents(initialPath); | |
| history.replaceState({ path: initialPath }, '', `/?path=${encodeURIComponent(initialPath)}`); // Set initial state | |
| showBrowseBtn.addEventListener('click', () => showSection('browse')); | |
| // --- Search Functionality --- | |
| searchInput.addEventListener('input', async (event) => { | |
| const searchTerm = event.target.value.trim(); | |
| if (searchTerm.length > 0) { | |
| showSection('search-results'); // Always show search results section | |
| searchResultsList.innerHTML = '<li>Searching...</li>'; // Show loading indicator | |
| saveSearchAsViewBtn.style.display = 'none'; // Hide save button while searching | |
| showSearchResultsToggleBtn.style.display = 'inline-block'; // Show the search results toggle button | |
| try { | |
| const response = await fetch(`/search?term=${encodeURIComponent(searchTerm)}`); | |
| const data = await response.json(); | |
| searchResultsList.innerHTML = ''; // Clear previous results | |
| currentSearchResults = data.results || {}; // Store results (filePath: [lineNumbers]) | |
| const filePaths = Object.keys(currentSearchResults); | |
| if (filePaths.length > 0) { | |
| filePaths.forEach(file => { | |
| const li = document.createElement('li'); | |
| const fileName = file.split('/').pop(); | |
| const lineNumbers = currentSearchResults[file]; | |
| let lineText = ''; | |
| if (lineNumbers && lineNumbers.length > 0) { | |
| lineText = ` (Lines: ${lineNumbers.sort((a,b)=>a-b).join(', ')})`; | |
| } | |
| // Use the new /browse endpoint for file links | |
| const relativePath = file; // Path is already relative from server | |
| li.innerHTML = `<span class="file-path"><a href="/browse/${encodeURIComponent(relativePath)}" target="_blank">${fileName}</a></span><span class="line-numbers">${lineText}</span>`; | |
| searchResultsList.appendChild(li); | |
| }); | |
| saveSearchAsViewBtn.style.display = 'inline-block'; // Show save button | |
| } else { | |
| searchResultsList.innerHTML = '<li>No results found.</li>'; | |
| saveSearchAsViewBtn.style.display = 'none'; | |
| } | |
| } catch (error) { | |
| console.error('Error fetching search results:', error); | |
| searchResultsList.innerHTML = '<li>Error fetching results. Please try again.</li>'; | |
| saveSearchAsViewBtn.style.display = 'none'; | |
| } | |
| } else { | |
| // If search bar is empty, hide search results and show browse | |
| showSection('browse'); | |
| showSearchResultsToggleBtn.style.display = 'none'; | |
| searchResultsList.innerHTML = ''; // Clear search results | |
| saveSearchAsViewBtn.style.display = 'none'; | |
| // Re-fetch current browse path content | |
| fetchDirectoryContents(currentBrowsePath); | |
| } | |
| }); | |
| // --- Save Search as View --- | |
| saveSearchAsViewBtn.addEventListener('click', () => { | |
| const filePaths = Object.keys(currentSearchResults); | |
| if (filePaths.length === 0) { | |
| alert('No search results to save!'); | |
| return; | |
| } | |
| const viewName = prompt('Enter a name for your new view:'); | |
| if (!viewName) return; | |
| const newView = createViewInLocalStorage(viewName, currentSearchResults); | |
| alert(`View '${newView.name}' created successfully!`); | |
| renderViewsList(); // Refresh views list | |
| showSection('views'); // Switch to views section | |
| }); | |
| // --- View Management (Client-Side) --- | |
| function renderViewsList() { | |
| const views = loadViewsFromLocalStorage(); | |
| viewsList.innerHTML = ''; | |
| if (views.length > 0) { | |
| views.forEach(view => { | |
| const li = document.createElement('li'); | |
| li.innerHTML = ` | |
| <span>${view.name}</span> | |
| <div class="controls"> | |
| <button data-view-id="${view.id}" class="open-view-btn">Open</button> | |
| <button data-view-id="${view.id}" class="delete-view-btn">Delete</button> | |
| </div> | |
| `; | |
| viewsList.appendChild(li); | |
| }); | |
| document.querySelectorAll('.open-view-btn').forEach(button => { | |
| button.addEventListener('click', (e) => openView(e.target.dataset.viewId)); | |
| }); | |
| document.querySelectorAll('.delete-view-btn').forEach(button => { | |
| button.addEventListener('click', (e) => deleteView(e.target.dataset.viewId)); | |
| }); | |
| } else { | |
| viewsList.innerHTML = '<li>No views created yet.</li>'; | |
| } | |
| } | |
| addNewViewBtn.addEventListener('click', () => { | |
| const viewName = prompt('Enter a name for your new empty view:'); | |
| if (!viewName) return; | |
| const newView = createViewInLocalStorage(viewName, {}); // Create empty view | |
| alert(`Empty view '${newView.name}' created successfully!`); | |
| renderViewsList(); // Refresh views list | |
| openView(newView.id); // Open the newly created empty view | |
| }); | |
| function openView(viewId) { | |
| const views = loadViewsFromLocalStorage(); | |
| const viewToOpen = views.find(v => v.id === viewId); | |
| if (viewToOpen) { | |
| currentView = viewToOpen; | |
| viewEditorTitle.textContent = `Editing View: ${currentView.name}`; | |
| renderViewItems(currentView.items); | |
| showSection('view-editor'); | |
| } else { | |
| alert('View not found.'); | |
| } | |
| } | |
| function deleteView(viewId) { | |
| if (!confirm('Are you sure you want to delete this view?')) return; | |
| if (deleteViewFromLocalStorage(viewId)) { | |
| alert('View deleted successfully!'); | |
| renderViewsList(); // Refresh views list | |
| showSection('views'); // Go back to views list | |
| } else { | |
| alert('Error deleting view.'); | |
| } | |
| } | |
| function renderViewItems(items) { | |
| viewItemsList.innerHTML = ''; | |
| items.forEach((item, index) => { | |
| const li = document.createElement('li'); | |
| li.dataset.itemId = item.id; | |
| li.dataset.itemType = item.type; | |
| let displayContent = ''; | |
| if (item.type === 'file') { | |
| const fileName = item.content.split('/').pop(); | |
| // Use the new /browse endpoint for file links in views too | |
| const relativePath = item.content; // Path is already relative from server | |
| displayContent = `<a href="/browse/${encodeURIComponent(relativePath)}" target="_blank">${fileName}</a>`; | |
| } else if (item.type === 'text') { | |
| // Display first few characters of text content | |
| displayContent = `Text Section: "${item.content.substring(0, 50)}${item.content.length > 50 ? '...' : ''}"`; | |
| } | |
| li.innerHTML = ` | |
| <span>${displayContent}</span> | |
| <div class="controls"> | |
| <button class="up-down move-up-btn" ${index === 0 ? 'disabled' : ''}>Up</button> | |
| <button class="up-down move-down-btn" ${index === items.length - 1 ? 'disabled' : ''}>Down</button> | |
| ${item.type === 'text' ? '<button class="edit-text-btn">Edit Text</button>' : ''} | |
| <button class="delete delete-item-btn">Delete</button> | |
| </div> | |
| ${item.type === 'text' ? `<textarea class="view-item-text-editor" style="display:none;">${item.content}</textarea>` : ''} | |
| `; | |
| viewItemsList.appendChild(li); | |
| }); | |
| // Add event listeners for new buttons | |
| document.querySelectorAll('.move-up-btn').forEach(button => { | |
| button.addEventListener('click', (e) => moveViewItem(e.target.closest('li').dataset.itemId, -1)); | |
| }); | |
| document.querySelectorAll('.move-down-btn').forEach(button => { | |
| button.addEventListener('click', (e) => moveViewItem(e.target.closest('li').dataset.itemId, 1)); | |
| }); | |
| document.querySelectorAll('.delete-item-btn').forEach(button => { | |
| button.addEventListener('click', (e) => deleteViewItem(e.target.closest('li').dataset.itemId)); | |
| }); | |
| document.querySelectorAll('.edit-text-btn').forEach(button => { | |
| button.addEventListener('click', (e) => toggleTextEditor(e.target.closest('li'))); | |
| }); | |
| } | |
| function moveViewItem(itemId, direction) { | |
| if (!currentView) return; | |
| const items = [...currentView.items]; // Create a mutable copy | |
| const index = items.findIndex(item => item.id == itemId); | |
| if (index === -1) return; | |
| const newIndex = index + direction; | |
| if (newIndex >= 0 && newIndex < items.length) { | |
| const [movedItem] = items.splice(index, 1); | |
| items.splice(newIndex, 0, movedItem); | |
| currentView.items = items; // Update currentView | |
| renderViewItems(currentView.items); // Re-render UI | |
| } | |
| } | |
| function deleteViewItem(itemId) { | |
| if (!currentView) return; | |
| if (!confirm('Are you sure you want to remove this item from the view?')) return; | |
| currentView.items = currentView.items.filter(item => item.id != itemId); | |
| renderViewItems(currentView.items); | |
| } | |
| function toggleTextEditor(listItem) { | |
| const editor = listItem.querySelector('.view-item-text-editor'); | |
| const displaySpan = listItem.querySelector('span'); | |
| if (editor.style.display === 'none') { | |
| editor.style.display = 'block'; | |
| displaySpan.style.display = 'none'; // Hide summary | |
| editor.focus(); | |
| } else { | |
| editor.style.display = 'none'; | |
| displaySpan.style.display = 'block'; // Show summary | |
| // Update the item's content in currentView | |
| const itemId = listItem.dataset.itemId; | |
| const item = currentView.items.find(i => i.id == itemId); | |
| if (item) { | |
| item.content = editor.value; | |
| // Re-render just this item's display content | |
| displaySpan.textContent = `Text Section: "${item.content.substring(0, 50)}${item.content.length > 50 ? '...' : ''}"`; | |
| } | |
| } | |
| } | |
| addTextSectionBtn.addEventListener('click', () => { | |
| if (!currentView) { | |
| alert('Please open or create a view first.'); | |
| return; | |
| } | |
| const newText = prompt('Enter the content for the new text section:'); | |
| if (newText !== null) { // User didn't cancel | |
| const newItem = new ViewItem('text', newText); // Use ViewItem class | |
| currentView.items.push(newItem); | |
| renderViewItems(currentView.items); | |
| } | |
| }); | |
| saveViewChangesBtn.addEventListener('click', () => { | |
| if (!currentView) return; | |
| // Before saving, ensure all open text editors are 'closed' and their content is updated | |
| document.querySelectorAll('.view-item-text-editor').forEach(editor => { | |
| if (editor.style.display === 'block') { | |
| const listItem = editor.closest('li'); | |
| toggleTextEditor(listItem); // This will update the item.content in currentView | |
| } | |
| }); | |
| if (updateViewInLocalStorage(currentView)) { | |
| alert('View changes saved successfully!'); | |
| } else { | |
| alert('Error saving view changes.'); | |
| } | |
| }); | |
| deleteCurrentViewBtn.addEventListener('click', () => { | |
| if (!currentView) return; | |
| if (!confirm(`Are you sure you want to delete the view "${currentView.name}"?`)) return; | |
| if (deleteViewFromLocalStorage(currentView.id)) { | |
| alert('View deleted successfully!'); | |
| currentView = null; // Clear current view | |
| showSection('views'); // Go back to views list | |
| renderViewsList(); // Refresh views list | |
| } else { | |
| alert('Error deleting view.'); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| self.sendResponse(connection, statusCode: "200 OK", headers: nil, body: html, contentType: "text/html") | |
| } | |
| private func sendSearchResults(_ connection: NWConnection, searchTerm: String) { | |
| let results = self.indexManager.search(for: searchTerm) | |
| // Convert file paths to be relative to the rootDirectory for client-side use | |
| let relativeResults: [String: [Int]] = results.reduce(into: [:]) { acc, entry in | |
| let (fullPath, lineNumbers) = entry | |
| // Ensure path is within the root directory and make it relative | |
| if let relativePath = fullPath.range(of: self.rootDirectory.path) { | |
| let pathAfterRoot = String(fullPath[relativePath.upperBound...]) | |
| // Remove leading slash if present, for consistency with /browse/ | |
| let cleanRelativePath = pathAfterRoot.hasPrefix("/") ? String(pathAfterRoot.dropFirst()) : pathAfterRoot | |
| acc[cleanRelativePath] = Array(lineNumbers.sorted()) | |
| } | |
| } | |
| let jsonResponse = ["results": relativeResults] | |
| do { | |
| let jsonData = try JSONSerialization.data(withJSONObject: jsonResponse, options: []) | |
| if let jsonString = String(data: jsonData, encoding: .utf8) { | |
| self.sendResponse(connection, statusCode: "200 OK", headers: nil, body: jsonString, contentType: "application/json") | |
| } else { | |
| self.sendResponse(connection, statusCode: "500 Internal Server Error", headers: nil, body: "Failed to create JSON string", contentType: "text/plain") | |
| } | |
| } catch { | |
| self.sendResponse(connection, statusCode: "500 Internal Server Error", headers: nil, body: "Failed to serialize JSON: \(error.localizedDescription)", contentType: "text/plain") | |
| } | |
| } | |
| private func sendResponse(_ connection: NWConnection, statusCode: String, headers: [String: String]?, body: String, contentType: String) { | |
| let httpResponse = """ | |
| HTTP/1.1 \(statusCode)\r | |
| Content-Type: \(contentType)\r | |
| Content-Length: \(body.utf8.count)\r | |
| Connection: close\r | |
| \r | |
| \(body) | |
| """ | |
| connection.send(content: httpResponse.data(using: .utf8), completion: .contentProcessed { error in | |
| if (error != nil) { | |
| print("Error sending response: \(error!.localizedDescription)") | |
| } | |
| }) | |
| } | |
| private func sendResponse(_ connection: NWConnection, statusCode: String, bodyData: Data, contentType: String) { | |
| let httpResponse = "HTTP/1.1 \(statusCode)\r\n" + | |
| "Content-Type: \(contentType)\r\n" + | |
| "Content-Length: \(bodyData.count)\r\n" + | |
| "Connection: close\r\n" + | |
| "\r\n" | |
| var responseData = Data(httpResponse.utf8) | |
| responseData.append(bodyData) | |
| connection.send(content: responseData, completion: .contentProcessed { error in | |
| if (error != nil) { | |
| print("Error sending response: \(error!.localizedDescription)") | |
| } | |
| }) | |
| } | |
| } | |
| // MARK: - ConsoleOutputRedirector (for capturing print statements) | |
| // A custom TextOutputStream to redirect print output to a UI element or stdout | |
| class ConsoleOutputRedirector: TextOutputStream { | |
| private var textView: NSTextView? | |
| private let queue = DispatchQueue(label: "com.fulltextindexer.consoleoutput", qos: .utility) | |
| private var originalStdout: FileHandle? | |
| init(textView: NSTextView? = nil) { | |
| self.textView = textView | |
| // Store original stdout to fallback if no text view is provided | |
| self.originalStdout = FileHandle.standardOutput | |
| } | |
| func write(_ string: String) { | |
| if let textView = textView { | |
| // Ensure UI updates happen on the main thread | |
| queue.async { | |
| DispatchQueue.main.async { | |
| let endOfDocument = textView.textStorage?.length ?? 0 | |
| textView.textStorage?.append(NSAttributedString(string: string)) | |
| textView.scrollRangeToVisible(NSRange(location: endOfDocument, length: string.utf8.count)) | |
| } | |
| } | |
| } else { | |
| // Fallback to standard output if no text view is set | |
| if let data = string.data(using: .utf8) { | |
| originalStdout?.write(data) | |
| } | |
| } | |
| } | |
| } | |
| // MARK: - AppWindowController (Manages the GUI window) | |
| class AppWindowController: NSObject, NSWindowDelegate { | |
| var window: NSWindow! | |
| var textView: NSTextView! | |
| var webServer: SimpleWebServer? // Reference to the web server to stop it | |
| override init() { | |
| super.init() | |
| setupWindow() | |
| } | |
| private func setupWindow() { | |
| let contentRect = NSRect(x: 0, y: 0, width: 600, height: 400) | |
| let styleMask: NSWindow.StyleMask = [.titled, .closable, .resizable, .miniaturizable] | |
| window = NSWindow(contentRect: contentRect, styleMask: styleMask, backing: .buffered, defer: false) | |
| window.center() | |
| window.title = "Full-Text Indexer Console" | |
| window.delegate = self // Set delegate to handle window close | |
| // Create a scroll view for the text view | |
| let scrollView = NSScrollView(frame: window.contentView!.bounds) | |
| scrollView.hasVerticalScroller = true | |
| scrollView.autoresizingMask = [.width, .height] | |
| // Create the text view | |
| textView = NSTextView(frame: scrollView.bounds) | |
| textView.isEditable = false | |
| textView.isSelectable = true | |
| textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) | |
| textView.backgroundColor = .black | |
| textView.textColor = .white | |
| textView.autoresizingMask = [.width, .height] | |
| scrollView.documentView = textView | |
| window.contentView?.addSubview(scrollView) | |
| // Add a "Stop Server and Exit" button | |
| let stopButton = NSButton(title: "Stop Server and Exit", target: self, action: #selector(stopServerAndExit)) | |
| stopButton.translatesAutoresizingMaskIntoConstraints = false | |
| window.contentView?.addSubview(stopButton) | |
| // Constraints for the button | |
| NSLayoutConstraint.activate([ | |
| stopButton.centerXAnchor.constraint(equalTo: window.contentView!.centerXAnchor), | |
| stopButton.bottomAnchor.constraint(equalTo: window.contentView!.bottomAnchor, constant: -20), | |
| stopButton.widthAnchor.constraint(equalToConstant: 200), | |
| stopButton.heightAnchor.constraint(equalToConstant: 30) | |
| ]) | |
| // Adjust text view frame to accommodate the button | |
| scrollView.bottomAnchor.constraint(equalTo: stopButton.topAnchor, constant: -20).isActive = true | |
| scrollView.topAnchor.constraint(equalTo: window.contentView!.topAnchor, constant: 20).isActive = true | |
| scrollView.leadingAnchor.constraint(equalTo: window.contentView!.leadingAnchor, constant: 20).isActive = true | |
| scrollView.trailingAnchor.constraint(equalTo: window.contentView!.trailingAnchor, constant: -20).isActive = true | |
| window.makeKeyAndOrderFront(nil) | |
| } | |
| // Action for the "Stop Server and Exit" button | |
| @objc private func stopServerAndExit() { | |
| print("Attempting to stop web server and exit...") | |
| webServer?.stop() // Stop the web server | |
| NSApp.terminate(self) // Terminate the application | |
| } | |
| // MARK: - NSWindowDelegate | |
| func windowWillClose(_ notification: Notification) { | |
| print("Window closed. Stopping server and exiting.") | |
| webServer?.stop() // Stop the web server if window is closed | |
| NSApp.terminate(self) // Terminate the application | |
| } | |
| } | |
| // MARK: - AppDelegate (for AppKit application lifecycle) | |
| // Only needed for macOS GUI applications | |
| #if os(macOS) | |
| class AppDelegate: NSObject, NSApplicationDelegate { | |
| let windowController = AppWindowController() | |
| func applicationDidFinishLaunching(_ notification: Notification) { | |
| // Window is already set up in init, just show it | |
| } | |
| func applicationWillTerminate(_ notification: Notification) { | |
| print("Application is terminating.") | |
| } | |
| func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { | |
| return true // Terminate the app when the last window is closed | |
| } | |
| } | |
| #endif | |
| // MARK: - Main execution block | |
| // Removed @main as requested | |
| struct FullTextIndexerCLI { | |
| static func main() { | |
| let arguments = CommandLine.arguments | |
| // Determine if window mode is requested | |
| let isWindowMode = arguments.contains("--window-mode") | |
| var directoryToScan: String? | |
| var userProvidedCachePath: String? // User might provide an explicit cache path | |
| // Parse arguments: <directory> [--window-mode] [cache_file_path.webstart] | |
| if arguments.count >= 2 { | |
| // If window mode is present, it might be the second or third argument | |
| if isWindowMode { | |
| if arguments.count >= 3 { // directory --window-mode cache | |
| directoryToScan = arguments[1] | |
| userProvidedCachePath = arguments.count > 3 ? arguments[3] : nil // Explicit cache path if provided | |
| } else { // directory --window-mode | |
| directoryToScan = arguments[1] | |
| userProvidedCachePath = nil | |
| } | |
| } else { // No window mode: directory [cache] | |
| directoryToScan = arguments[1] | |
| userProvidedCachePath = arguments.count > 2 ? arguments[2] : nil | |
| } | |
| } | |
| guard let finalDirectoryToScan = directoryToScan else { | |
| print(IndexerError.invalidArguments.description) | |
| exit(1) | |
| } | |
| let rootDirectoryURL = URL(fileURLWithPath: finalDirectoryToScan) | |
| let finalCacheFilePath: String | |
| if let userCache = userProvidedCachePath { | |
| finalCacheFilePath = userCache // Use user's explicit cache path | |
| } else { | |
| // Default cache path is "index.webstart" inside the directory being indexed | |
| finalCacheFilePath = rootDirectoryURL.appendingPathComponent("index.webstart").path | |
| } | |
| let indexManager = IndexManager(rootDirectory: rootDirectoryURL) | |
| let indexingQueue = DispatchQueue.global(qos: .background) | |
| let serverPort: UInt16 = 8080 // Default port for the web server | |
| // Use a DispatchGroup to signal indexing completion to the main thread/web server | |
| let indexingGroup = DispatchGroup() | |
| var indexingFinished = false // State to track indexing completion | |
| // Create the web server instance early so we can pass its reference | |
| let webServer = SimpleWebServer(port: serverPort, indexManager: indexManager, rootDirectory: rootDirectoryURL) { | |
| indexingFinished | |
| } | |
| // Setup console output redirection | |
| let consoleRedirector: ConsoleOutputRedirector | |
| if isWindowMode { | |
| #if os(macOS) | |
| // Initialize AppKit application | |
| NSApplication.shared.setActivationPolicy(.regular) // Make app appear in Dock | |
| let appDelegate = AppDelegate() | |
| NSApplication.shared.delegate = appDelegate | |
| // Pass the web server instance to the window controller for shutdown | |
| appDelegate.windowController.webServer = webServer | |
| consoleRedirector = ConsoleOutputRedirector(textView: appDelegate.windowController.textView) | |
| #else | |
| print(IndexerError.windowModeRequiresMacOS.description) | |
| exit(1) | |
| #endif | |
| } else { | |
| consoleRedirector = ConsoleOutputRedirector() // Default to stdout | |
| } | |
| // Redirect Swift's global print function | |
| var outputStream: TextOutputStream = consoleRedirector | |
| /*Swift.print = { item, separator, terminator in | |
| var output = "" | |
| debugPrint(item, separator: separator, terminator: "", to: &output) | |
| outputStream.write(output + terminator) | |
| }*/ | |
| // Start indexing in the background | |
| indexingGroup.enter() | |
| indexingQueue.async { | |
| defer { indexingGroup.leave() } // Ensure we leave the group | |
| do { | |
| let cacheLoaded = try indexManager.loadCache(from: finalCacheFilePath) | |
| if !cacheLoaded { | |
| print("Indexing in background...") | |
| try indexManager.indexDirectory(atPath: finalDirectoryToScan) | |
| try indexManager.saveCache(to: finalCacheFilePath) | |
| } | |
| indexingFinished = true // Mark indexing as complete | |
| print("Background indexing complete.") | |
| } catch { | |
| print("An error occurred during background indexing: \(error.localizedDescription)") | |
| if let indexerError = error as? IndexerError { | |
| print(indexerError.description) | |
| } | |
| // The web server will still run even if indexing fails, | |
| // but search results will be based on whatever was loaded/indexed. | |
| } | |
| } | |
| do { | |
| try webServer.start() | |
| print("Web server started. Open your browser to http://localhost:\(serverPort)") | |
| if isWindowMode { | |
| NSApp.run() | |
| } else { | |
| RunLoop.main.run() | |
| } | |
| } catch { | |
| print(IndexerError.webServerStartFailed(error).description) | |
| exit(1) | |
| } | |
| } | |
| } | |
| autoreleasepool { | |
| FullTextIndexerCLI.main() | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
The script need to be compiled with
swiftc -sdk $(xcrun --sdk macosx --show-sdk-path) -framework PDFKit -framework Network -framework AppKit -framework Foundation -o fulltextindexer fulltextindexer.swift