Skip to content

Instantly share code, notes, and snippets.

@beeksiwaais
Last active March 19, 2026 16:23
Show Gist options
  • Select an option

  • Save beeksiwaais/0555d0e0ae42c1b07e27478294d0561f to your computer and use it in GitHub Desktop.

Select an option

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
// 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()
}
@beeksiwaais
Copy link
Copy Markdown
Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment