diff --git a/PeculiarLog.xcodeproj/project.pbxproj b/PeculiarLog.xcodeproj/project.pbxproj index 26bef23..f514c9d 100644 --- a/PeculiarLog.xcodeproj/project.pbxproj +++ b/PeculiarLog.xcodeproj/project.pbxproj @@ -314,6 +314,7 @@ CODE_SIGN_ENTITLEMENTS = PeculiarLog/PeculiarLog.entitlements; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; HEADER_SEARCH_PATHS = /usr/local/opt/hyperscan/include/hs; INFOPLIST_FILE = PeculiarLog/Info.plist; @@ -323,6 +324,7 @@ ); LIBRARY_SEARCH_PATHS = /usr/local/lib; MACOSX_DEPLOYMENT_TARGET = 10.13; + MARKETING_VERSION = 1.2; OTHER_LDFLAGS = "-lhs"; PRODUCT_BUNDLE_IDENTIFIER = tech.peculiar.PeculiarLog; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -342,6 +344,7 @@ CODE_SIGN_ENTITLEMENTS = PeculiarLog/PeculiarLog.entitlements; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; GCC_GENERATE_DEBUGGING_SYMBOLS = NO; HEADER_SEARCH_PATHS = /usr/local/opt/hyperscan/include/hs; @@ -352,6 +355,7 @@ ); LIBRARY_SEARCH_PATHS = /usr/local/lib; MACOSX_DEPLOYMENT_TARGET = 10.13; + MARKETING_VERSION = 1.2; OTHER_LDFLAGS = "-lhs"; PRODUCT_BUNDLE_IDENTIFIER = tech.peculiar.PeculiarLog; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/PeculiarLog/AppDelegate.swift b/PeculiarLog/AppDelegate.swift index 7064669..55af279 100644 --- a/PeculiarLog/AppDelegate.swift +++ b/PeculiarLog/AppDelegate.swift @@ -49,5 +49,36 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + @IBAction func exportFiltered(_ sender: Any) { + print("[+] exporting filtered...") + let documentController = NSDocumentController.shared + + let savePanel = NSSavePanel() + savePanel.canCreateDirectories = true + savePanel.showsTagField = false + savePanel.nameFieldStringValue = (documentController.currentDocument?.displayName)! + let res = savePanel.runModal() + if(res == NSApplication.ModalResponse.OK) { + if let fileName = savePanel.url { + (documentController.currentDocument as? Document)?.exportFiltered(fileName: fileName) + } + } + } + + @IBAction func exportSelected(_ sender: Any) { + print("[+] exporting selected...") + let documentController = NSDocumentController.shared + + let savePanel = NSSavePanel() + savePanel.canCreateDirectories = true + savePanel.showsTagField = false + savePanel.nameFieldStringValue = (documentController.currentDocument?.displayName)! + let res = savePanel.runModal() + if(res == NSApplication.ModalResponse.OK) { + if let fileName = savePanel.url { + (documentController.currentDocument as? Document)?.exportSelected(fileName: fileName) + } + } + } } diff --git a/PeculiarLog/Base.lproj/Main.storyboard b/PeculiarLog/Base.lproj/Main.storyboard index 35a33bf..eb4ef4e 100644 --- a/PeculiarLog/Base.lproj/Main.storyboard +++ b/PeculiarLog/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -58,7 +58,7 @@ - + @@ -79,6 +79,19 @@ + + + + + + + + + + + + + @@ -597,6 +610,18 @@ + + + + + + + + + + + + @@ -689,28 +714,74 @@ - + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + @@ -718,61 +789,64 @@ - + - - + + + + + + + + + + - + - + - - + - + + - + + + + + - + @@ -857,7 +931,7 @@ - + @@ -897,7 +971,7 @@ - + @@ -1113,7 +1187,7 @@ - + diff --git a/PeculiarLog/Document.swift b/PeculiarLog/Document.swift index b92acfc..af439f6 100644 --- a/PeculiarLog/Document.swift +++ b/PeculiarLog/Document.swift @@ -36,5 +36,59 @@ class Document: NSDocument { override func read(from url: URL, ofType typeName: String) throws { searchEngine = SearchEngine(url.path) } + + func exportFiltered(fileName:URL) { + if let contentVC = self.windowControllers.first?.contentViewController as? ViewController { + do { + let fileHandle = FileHandle(forWritingAtPath: fileName.path) + if fileHandle == nil { + try "".write(to: fileName, atomically: true, encoding: String.Encoding.utf8) + } + + if let fileHandle = FileHandle(forWritingAtPath: fileName.path) { + defer { + fileHandle.closeFile() + Swift.print("[+] saved to \(fileName.path)") + } + fileHandle.seekToEndOfFile() + contentVC.filteredContent(fileHandle) + } + } catch { + let alert = NSAlert() + alert.messageText = "PeculiarLog" + alert.informativeText = "Unable to export filtered content." + alert.alertStyle = NSAlert.Style.critical + alert.addButton(withTitle: "OK") + alert.runModal() + } + } + } + + func exportSelected(fileName:URL) { + if let contentVC = self.windowControllers.first?.contentViewController as? ViewController { + do { + let fileHandle = FileHandle(forWritingAtPath: fileName.path) + if fileHandle == nil { + try "".write(to: fileName, atomically: true, encoding: String.Encoding.utf8) + } + + if let fileHandle = FileHandle(forWritingAtPath: fileName.path) { + defer { + Swift.print("[+] saved to \(fileName.path)") + fileHandle.closeFile() + } + fileHandle.seekToEndOfFile() + contentVC.selectedContent(fileHandle) + } + } catch { + let alert = NSAlert() + alert.messageText = "PeculiarLog" + alert.informativeText = "Unable to export filtered content." + alert.alertStyle = NSAlert.Style.critical + alert.addButton(withTitle: "OK") + alert.runModal() + } + } + } } diff --git a/PeculiarLog/Info.plist b/PeculiarLog/Info.plist index 71ce7bc..ed3b475 100644 --- a/PeculiarLog/Info.plist +++ b/PeculiarLog/Info.plist @@ -38,13 +38,13 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright - Copyright © 2019 Alexander Hude. + Copyright © 2020 Alexander Hude. All rights reserved. NSMainStoryboardFile Main diff --git a/PeculiarLog/LogViewController.swift b/PeculiarLog/LogViewController.swift index bb9d420..0e7f1c2 100644 --- a/PeculiarLog/LogViewController.swift +++ b/PeculiarLog/LogViewController.swift @@ -8,10 +8,17 @@ import Cocoa +public protocol LogViewDelegate { + func patternCompilationError(_ error: String) + func selectionChanged() +} + class LogViewController: NSViewController { @IBOutlet var tableView: NSTableView! + var delegate: LogViewDelegate? + private var currentPattern: String = "" private var currentMatchColor: NSColor = NSColor.red private var currentScopeColor: NSColor = NSColor.textColor @@ -60,13 +67,20 @@ class LogViewController: NSViewController { tableView.tableColumns[cindex].isHidden = !show } - func filterLog(with pattern: String, matchColor: NSColor, scopeColor: NSColor) { + func filterLog(with pattern: String, matchColor: NSColor, scopeColor: NSColor, reportError: Bool = false) { guard let engine = self.representedObject as? SearchEngine else { return } currentPattern = pattern currentMatchColor = matchColor currentScopeColor = scopeColor - guard engine.setPattern(pattern) else { return } + let (result, error) = engine.setPattern(pattern) + guard result else { + if (reportError) { + delegate?.patternCompilationError(error) + } + return + } + if (pattern.count != 0) { guard engine.filter() else { return } } @@ -78,6 +92,39 @@ class LogViewController: NSViewController { tableView.reloadData() } + func gotoAbsLine(_ line: Int) -> Bool { + guard let engine = self.representedObject as? SearchEngine else { return false } + + guard + line != 0, + line <= engine.totalLines + else { return false } + + var row = 0 + if (engine.isFiltered) { + row = engine.getRowForAbsLine(line) + guard row != -1 else { return false } + } else { + row = line - 1 + } + tableView.scrollRowToVisible(row: row, animated: true) + return true; + } +} + +extension NSTableView { + func scrollRowToVisible(row: Int, animated: Bool) { + self.selectRowIndexes(IndexSet.init(integer: row), byExtendingSelection: false) + let rowRect = self.frameOfCell(atColumn: 1, row: row) + if let scrollView = self.enclosingScrollView { + let centredPoint = NSMakePoint(0.0, rowRect.origin.y + (rowRect.size.height / 2) - ((scrollView.frame.size.height) / 2)) + if animated { + scrollView.contentView.animator().setBoundsOrigin(centredPoint) + } else { + self.scroll(centredPoint) + } + } + } } extension String { @@ -95,6 +142,27 @@ extension String { extension LogViewController: NSTableViewDataSource, NSTableViewDelegate { + func tableViewSelectionDidChange(_ notification: Notification) { + delegate?.selectionChanged() + } + + @objc func copy(_ sender: AnyObject) { + guard let engine = self.representedObject as? SearchEngine else { return; } + + var selectedLines = "" + let indexSet = tableView.selectedRowIndexes + + for (_, rowIndex) in indexSet.enumerated() { + let lineInfo = engine.getLine(rowIndex) + selectedLines += lineInfo.line + selectedLines += "\n" + } + + let pasteBoard = NSPasteboard.general + pasteBoard.clearContents() + pasteBoard.setString(selectedLines, forType:NSPasteboard.PasteboardType.string) + } + func numberOfRows(in tableView: NSTableView) -> Int { guard let engine = self.representedObject as? SearchEngine else { return 0 } return engine.lineCount @@ -111,7 +179,7 @@ extension LogViewController: NSTableViewDataSource, NSTableViewDelegate { rowInfoCache = engine.getLine(row) guard let lineInfo = rowInfoCache else { return nil; } - cell.textField?.stringValue = String(lineInfo.number + 1) + cell.textField?.stringValue = String(lineInfo.number) cell.wantsLayer = true cell.layer?.backgroundColor = NSColor(named: NSColor.Name("LineNumberBackgroundColor"))?.cgColor return cell diff --git a/PeculiarLog/PeculiarLog.entitlements b/PeculiarLog/PeculiarLog.entitlements index f2ef3ae..19afff1 100644 --- a/PeculiarLog/PeculiarLog.entitlements +++ b/PeculiarLog/PeculiarLog.entitlements @@ -2,9 +2,9 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + diff --git a/PeculiarLog/SearchEngine/HyperscanEngine.cpp b/PeculiarLog/SearchEngine/HyperscanEngine.cpp index 0147f0f..e15b3fe 100644 --- a/PeculiarLog/SearchEngine/HyperscanEngine.cpp +++ b/PeculiarLog/SearchEngine/HyperscanEngine.cpp @@ -12,6 +12,7 @@ #define DEBUG_BLOCKS 0 #define DEBUG_GETLINE 0 +#define DEBUG_GETROW 0 static const unsigned int SE_HS_EOL_ID = 0x5EE0; static const unsigned int SE_HS_PATTERN_ID = 0x5EAA; @@ -216,6 +217,8 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo m_recentBlock = blockIdx; m_recentLineOffset = lineOffset; + lineInfo->number++; // correct display line number (starting from 1) + // skip \r at the end of the line if exists if (lineInfo->length && lineInfo->line[lineInfo->length-1] == '\r') lineInfo->length--; @@ -230,7 +233,7 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo lineOffset = m_recentLineOffset; absLineOffset = m_recentAbsLineOffset; } else { - lineOffset = m_blocks[blockIdx].filteredLines + m_blocks[blockIdx].borrowHeadLines + m_blocks[blockIdx].borrowTailLines; + lineOffset = m_blocks[blockIdx].filteredLines + m_blocks[blockIdx].lendedHeadLines + m_blocks[blockIdx].lendedTailLines; absLineOffset = m_blocks[blockIdx].lines; } @@ -239,7 +242,7 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo currentLine = lineOffset; lineInfo->number = absLineOffset; blockIdx++; - lineOffset += m_blocks[blockIdx].filteredLines + m_blocks[blockIdx].borrowHeadLines + m_blocks[blockIdx].borrowTailLines; + lineOffset += m_blocks[blockIdx].filteredLines + m_blocks[blockIdx].lendedHeadLines + m_blocks[blockIdx].lendedTailLines; absLineOffset += m_blocks[blockIdx].lines; } @@ -288,10 +291,10 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo if (m_blocks[blockIdx].filteredLines) { bool lineFound = false; - uint32_t borrowTailLines = m_blocks[blockIdx].borrowTailLines; - uint32_t borrowHeadLines = m_blocks[blockIdx].borrowHeadLines; + uint32_t lendedTailLines = m_blocks[blockIdx].lendedTailLines; + uint32_t lendedHeadLines = m_blocks[blockIdx].lendedHeadLines; if (btracker->hasBaseLine()) { - // return 'before' scope including base line + // return line from 'before' scope including base line if (! btracker->isEmpty()) { __unused auto topLine = btracker->getTopScopeLine(); assert(currentLine == topLine); @@ -312,7 +315,7 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo // reset 'before' tracker if we reached match line btracker->reset(); } - } else if (currentLine > lineOffset - borrowHeadLines) { + } else if (currentLine > lineOffset - lendedHeadLines) { if (btracker->getCount()) { uint64_t curPos = btracker->popScope(lineInfo->length); assert(curPos != -1); @@ -339,7 +342,7 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo atracker->pushBaseLine(scopeBaseLine, lastHit + searchPos, uint32_t(to - lastHit - 1)); btracker->pushBaseLine(scopeBaseLine, lastHit + searchPos, uint32_t(to - lastHit - 1)); - // start returning 'before' scope + // return line from 'before' scope if (btracker->getCount()) { absNumber -= btracker->getCount(); uint64_t curPos = btracker->popScope(length); @@ -372,9 +375,8 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo if (atracker->hasBaseLine()) { if (!atracker->isFull()) { - // return 'after' scope - if (!atracker->pushScope(lastHit + searchPos, len)) - atracker->reset(); + // return line from 'after' scope + atracker->pushScope(lastHit + searchPos, len); if (currentLine == number) { length = len; isScope = true; @@ -382,9 +384,9 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo } currentLine++; } else { - btracker->pushScope(lastHit + searchPos, len); // TODO: investigate + btracker->pushScope(lastHit + searchPos, len); } - } else if (currentLine < baseLine + borrowTailLines) { + } else if (currentLine < baseLine + lendedTailLines) { if (currentLine == number) { length = uint32_t(to - lastHit - 1); isScope = true; @@ -392,7 +394,7 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo } currentLine++; } else { - btracker->pushScope(lastHit + searchPos, len); // TODO: investigate + btracker->pushScope(lastHit + searchPos, len); } } // save pointer to the next line, reset pattern match flag @@ -411,10 +413,20 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo return UnknownError; } - // borrow head search should reach the end of block - if (borrowHeadLines && res == HS_SUCCESS) { + // borrow for head search should reach the end of block + if (lendedHeadLines && res == HS_SUCCESS) { if (btracker->getCount()) { + // align scope with lended lines + btracker->dropScope(btracker->getCount() - lendedHeadLines); + // correct abs line number + lineInfo->number -= btracker->getCount(); + // return line from 'before' scope uint64_t curPos = btracker->popScope(lineInfo->length); + while((currentLine != number) && (curPos != -1)) { + curPos = btracker->popScope(lineInfo->length); + currentLine++; + lineInfo->number++; + } assert(curPos != -1); lastHit = curPos - searchPos; @@ -425,10 +437,10 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo } else { // search borrowed line with scope in the block without matches bool lineFound = false; - uint32_t borrowTailLines = m_blocks[blockIdx].borrowTailLines; - uint32_t borrowHeadLines = m_blocks[blockIdx].borrowHeadLines; + uint32_t lendedTailLines = m_blocks[blockIdx].lendedTailLines; + uint32_t lendedHeadLines = m_blocks[blockIdx].lendedHeadLines; - if (currentLine > lineOffset - borrowHeadLines) { + if (currentLine > lineOffset - lendedHeadLines) { if (btracker->getCount()) { uint64_t curPos = btracker->popScope(lineInfo->length); assert(curPos != -1); @@ -444,7 +456,7 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo [&, &absNumber = lineInfo->number, &length = lineInfo->length, &isScope = lineInfo->scope] (unsigned int id, unsigned long long from, unsigned long long to, unsigned int flags, void *ctx) -> int { uint32_t len = uint32_t(to - lastHit - 1); - if (currentLine < baseLine + borrowTailLines) { + if (currentLine < baseLine + lendedTailLines) { // return lines for the tail of previous block if (currentLine == number) { length = len; @@ -453,7 +465,7 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo } currentLine++; } - if (borrowHeadLines) { + if (lendedHeadLines) { // borrow lines to the head of next block btracker->pushScope(lastHit + searchPos, len); } @@ -468,9 +480,19 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo } // borrow head search should reach the end of block - if (borrowHeadLines && res == HS_SUCCESS) { + if (lendedHeadLines && res == HS_SUCCESS) { if (btracker->getCount()) { + // align scope with lended lines + btracker->dropScope(btracker->getCount() - lendedHeadLines); + // correct abs line number + lineInfo->number -= btracker->getCount(); + // return line from 'before' scope uint64_t curPos = btracker->popScope(lineInfo->length); + while((currentLine != number) && (curPos != -1)) { + curPos = btracker->popScope(lineInfo->length); + currentLine++; + lineInfo->number++; + } assert(curPos != -1); lastHit = curPos - searchPos; @@ -520,6 +542,8 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo m_recentLineOffset = lineOffset; m_recentAbsLineOffset = absLineOffset; + lineInfo->number++; // correct display line number (starting from 1) + // skip \r at the end of the line if exists if (lineInfo->length && lineInfo->line[lineInfo->length-1] == '\r') lineInfo->length--; @@ -528,6 +552,268 @@ SearchEngineError HyperscanEngine::getLine(uint32_t number, SELineInfo* lineInfo return NoError; } +SearchEngineError HyperscanEngine::getRowForAbsLine(uint32_t absLine, uint32_t* row) +{ + if (! row) + return BadArgument; + + if (! m_filtered) { + *row = absLine; + } + + // find block with line + uint32_t dummy = 0; + int blockIdx = 0; + + uint32_t absLineCount = 0; + uint32_t lineOffset = m_blocks[blockIdx].filteredLines + m_blocks[blockIdx].lendedHeadLines + m_blocks[blockIdx].lendedTailLines; + uint32_t absLineOffset = m_blocks[blockIdx].lines; + + uint32_t currentLine = 0; + while (absLine >= absLineOffset) { + currentLine = lineOffset; + absLineCount = absLineOffset; + blockIdx++; + lineOffset += m_blocks[blockIdx].filteredLines + m_blocks[blockIdx].lendedHeadLines + m_blocks[blockIdx].lendedTailLines; + absLineOffset += m_blocks[blockIdx].lines; + } + + uint32_t baseLine = currentLine; + + uint64_t basePos = m_blocks[blockIdx].byteOffset; + uint64_t searchPos = basePos; + uint64_t scanSize = m_blocks[blockIdx].size - (searchPos - basePos); + bool patternMatch = false; + +#if DEBUG_GETROW + printf("[#] get row for absolute line %d in block %2d starting with line %d at offset %lld\n", absLine, blockIdx, currentLine, searchPos); +#endif + + if (m_scopeBefore || m_scopeAfter) { + // advanced search with scope support + ScopeTracker btracker; + ScopeTracker atracker; + btracker.setSize(m_scopeBefore); + atracker.setSize(m_scopeAfter); + + if (m_blocks[blockIdx].filteredLines) { + + bool lineFound = false; + uint32_t lendedTailLines = m_blocks[blockIdx].lendedTailLines; + uint32_t lendedHeadLines = m_blocks[blockIdx].lendedHeadLines; + if (btracker.hasBaseLine()) { + // return line from 'before' scope including base line + if (! btracker.isEmpty()) { + __unused auto topLine = btracker.getTopScopeLine(); + assert(currentLine == topLine); + // search offset points to the right line, pop scope line + __unused auto curPos = btracker.popScope(dummy); + assert(curPos == searchPos); + lineFound = true; + } else { + __unused auto topLine = btracker.getTopScopeLine(); + assert(currentLine == topLine); + // search offset points to the right line, pop base line + __unused auto curPos = btracker.popBaseLine(dummy); + assert(curPos == searchPos); + lineFound = true; + + // reset 'before' tracker if we reached match line + btracker.reset(); + } + } else if (currentLine > lineOffset - lendedHeadLines) { + if (btracker.getCount()) { + __unused auto curPos = btracker.popScope(dummy); + assert(curPos != -1); + lineFound = true; + } + } + + if (!lineFound) { + // search filtered line with scope + auto res = hs_scan(m_patternDB, m_mem + searchPos, (unsigned int)scanSize, 0, m_filterScratch, + [&, &btracker = btracker, &atracker = atracker] + (unsigned int id, unsigned long long from, unsigned long long to, unsigned int flags, void *ctx) -> int { + if (id == SE_HS_EOL_ID) { + // for every EOL match check if we have pattern match within this line + // in this case get line length and return when line counter matches target number + if (patternMatch) { + uint32_t scopeBaseLine = currentLine + btracker.getCount(); + + // setup scope trackers + atracker.reset(); + atracker.pushBaseLine(scopeBaseLine, 0, 0); + btracker.pushBaseLine(scopeBaseLine, 0, 0); + + // return line from 'before' scope + if (btracker.getCount()) { + absLineCount -= btracker.getCount(); + uint64_t curPos = btracker.popScope(dummy); + while(curPos != -1) { + if (absLineCount >= absLine) { + return 1; + } + curPos = btracker.popScope(dummy); + currentLine++; + absLineCount++; + } + } + + // reset 'before' tracker if we reached match line + btracker.reset(); + + // check if we reached desired pattern match + if (absLineCount >= absLine) { + return 1; + } + currentLine++; + } else { + // keep tracking scope lines + + if (atracker.hasBaseLine()) { + if (!atracker.isFull()) { + // return 'after' scope + atracker.pushScope(0, 0); + if (absLineCount >= absLine) { + return 1; + } + currentLine++; + } else { + btracker.pushScope(0, 0); + } + } else if (currentLine < baseLine + lendedTailLines) { + if (absLineCount >= absLine) { + return 1; + } + currentLine++; + } else { + btracker.pushScope(0, 0); + } + } + // save pointer to the next line, reset pattern match flag + absLineCount++; + patternMatch = false; + } else if (id == SE_HS_PATTERN_ID) { + // for every pattern match within the line set flag + patternMatch = true; + } + return 0; + } + ); + if (!(res == HS_SUCCESS || res == HS_SCAN_TERMINATED)) { + printf("[!] unable to find filtered line %d with scope\n", absLine); + return UnknownError; + } + + // borrow head search should reach the end of block + if (lendedHeadLines && res == HS_SUCCESS) { + if (btracker.getCount()) { + // align scope with lended lines + btracker.dropScope(btracker.getCount() - lendedHeadLines); + // correct abs line number + absLineCount -= btracker.getCount(); + // return line from 'before' scope + uint64_t curPos = btracker.popScope(dummy); + while((currentLine != absLine) && (curPos != -1)) { + curPos = btracker.popScope(absLineCount); + currentLine++; + } + assert(curPos != -1); + } + } + } + } else { + // search borrowed line with scope in the block without matches + bool lineFound = false; + uint32_t lendedTailLines = m_blocks[blockIdx].lendedTailLines; + uint32_t lendedHeadLines = m_blocks[blockIdx].lendedHeadLines; + + if (currentLine > lineOffset - lendedHeadLines) { + if (btracker.getCount()) { + __unused auto curPos = btracker.popScope(dummy); + assert(curPos != -1); + + lineFound = true; + } + } + + if (!lineFound) { + auto res = hs_scan(m_eolDB, m_mem + searchPos, (unsigned int)scanSize, 0, m_baseScratch, + [&] + (unsigned int id, unsigned long long from, unsigned long long to, unsigned int flags, void *ctx) -> int { + if (absLineCount < baseLine + lendedTailLines) { + // return lines for the tail of previous block + currentLine++; + } + if (lendedHeadLines) { + // borrow lines to the head of next block + btracker.pushScope(0, 0); + } + absLineCount++; + if (absLineCount >= absLine) { + return 1; + } + return 0; + } + ); + if (!(res == HS_SUCCESS || res == HS_SCAN_TERMINATED)) { + printf("[!] unable to find borrowed line %d with scope\n", absLine); + return UnknownError; + } + + // borrow head search should reach the end of block + if (lendedHeadLines && res == HS_SUCCESS) { + if (btracker.getCount()) { + // align scope with lended lines + btracker.dropScope(btracker.getCount() - lendedHeadLines); + // correct abs line number + absLineCount -= btracker.getCount(); + // returning line from 'before' scope + uint64_t curPos = btracker.popScope(dummy); + while((currentLine != absLine) && (curPos != -1)) { + curPos = btracker.popScope(absLineCount); + currentLine++; + } + assert(curPos != -1); + } + } + } + } + } else { + // optimized search without scope support + auto res = hs_scan(m_patternDB, m_mem + searchPos, (unsigned int)scanSize, 0, m_filterScratch, + [&] + (unsigned int id, unsigned long long from, unsigned long long to, unsigned int flags, void *ctx) -> int { + if (id == SE_HS_EOL_ID) { + // for every EOL match check if we have pattern match within this line + // in this case return when absolute line counter more or equal than target number + if (patternMatch) { + currentLine++; + } + // save pointer to the next line, reset pattern match flag + absLineCount++; + if (absLineCount >= absLine) { + return 1; + } + patternMatch = false; + } else if (id == SE_HS_PATTERN_ID) { + // for every pattern match within the line set flag + patternMatch = true; + } + return 0; + } + ); + if (!(res == HS_SUCCESS || res == HS_SCAN_TERMINATED)) { + printf("[!] unable to find filtered line %d\n", absLine); + return UnknownError; + } + } + + *row = (currentLine)? currentLine - 1 : currentLine; + + return NoError; +} + SearchEngineError HyperscanEngine::setIgnoreCase(bool ignoreCase) { m_ignoreCase = ignoreCase; @@ -548,7 +834,7 @@ SearchEngineError HyperscanEngine::setScope(uint32_t before, uint32_t after) return NoError; } -SearchEngineError HyperscanEngine::setPattern(const char* pattern) +SearchEngineError HyperscanEngine::setPattern(const char* pattern, char* error) { const char* patterns[2] = { s_eolPattern, @@ -578,6 +864,10 @@ SearchEngineError HyperscanEngine::setPattern(const char* pattern) hs_compile_error_t *compile_err; if (hs_compile_multi(patterns, flags, s_filterIDs, 2, HS_MODE_BLOCK, nullptr, &m_patternDB, &compile_err) != HS_SUCCESS) { printf("[!] unable to compile filter pattern: %s\n", compile_err->message); + if (error) { + size_t len = strlen(compile_err->message); + strncpy(error, compile_err->message, (len > MAX_ERROR_LENGTH)? MAX_ERROR_LENGTH : len); + } hs_free_compile_error(compile_err); return UnknownError; } @@ -599,8 +889,8 @@ SearchEngineError HyperscanEngine::setPattern(const char* pattern) m_blocks[block].scopeLines = 0; m_blocks[block].headLines = 0; m_blocks[block].tailLines = 0; - m_blocks[block].borrowHeadLines = 0; - m_blocks[block].borrowTailLines = 0; + m_blocks[block].lendedHeadLines = 0; + m_blocks[block].lendedTailLines = 0; m_beforeTracker[block].reset(); m_afterTracker[block].reset(); } @@ -737,7 +1027,7 @@ SearchEngineError HyperscanEngine::filter(uint32_t blockIdx, SEBlockInfo* info) info->maxLength = maxLength; #if DEBUG_BLOCKS - printf("[#] filter block %2d ready (%d lines, %2d cols, +%d scope lines %+d|%+d)\n", + printf("[#] filter block %2d ready (%d lines, %3d cols, +%d scope lines %+d|%+d)\n", blockIdx, info->lines - block->scopeLines, info->maxLength, block->scopeLines, block->headLines, block->tailLines); #endif diff --git a/PeculiarLog/SearchEngine/HyperscanEngine.hpp b/PeculiarLog/SearchEngine/HyperscanEngine.hpp index 01d60ff..86a26ae 100644 --- a/PeculiarLog/SearchEngine/HyperscanEngine.hpp +++ b/PeculiarLog/SearchEngine/HyperscanEngine.hpp @@ -23,10 +23,11 @@ class HyperscanEngine : public SearchEngine { void close() override; SearchEngineError getLine(uint32_t number, SELineInfo* lineInfo) override; + SearchEngineError getRowForAbsLine(uint32_t absLine, uint32_t* row) override; SearchEngineError setIgnoreCase(bool ignoreCase) override; SearchEngineError setScope(uint32_t before, uint32_t after) override; - SearchEngineError setPattern(const char* pattern) override; + SearchEngineError setPattern(const char* pattern, char* error) override; SearchEngineError filter(uint32_t blockIdx, SEBlockInfo* info) override; private: diff --git a/PeculiarLog/SearchEngine/ScopeTracker.hpp b/PeculiarLog/SearchEngine/ScopeTracker.hpp index d6ea19b..4d9346d 100644 --- a/PeculiarLog/SearchEngine/ScopeTracker.hpp +++ b/PeculiarLog/SearchEngine/ScopeTracker.hpp @@ -116,6 +116,21 @@ class ScopeTracker { return m_scopeLines[index].pos; } + uint64_t dropScope(uint32_t number) { + if (m_size == 0) + return 0; + + if (number > m_count) + number = m_count; + + if (number) { + m_count -= number; + m_startIndex = wrap(m_startIndex + number); + } + + return number; + } + void reset() { m_count = 0; diff --git a/PeculiarLog/SearchEngine/SearchEngine.cpp b/PeculiarLog/SearchEngine/SearchEngine.cpp index 359e736..f0ab53c 100644 --- a/PeculiarLog/SearchEngine/SearchEngine.cpp +++ b/PeculiarLog/SearchEngine/SearchEngine.cpp @@ -104,7 +104,7 @@ SearchEngineError SearchEngine::mergeScope(uint32_t *filteredLines) carry = 0; if(tailLines < 0) { carry = (linesLeft > 0)? headLines - linesLeft : (headLines > 0)? headLines : 0; - m_blocks[i].borrowTailLines = carry; + m_blocks[i].lendedTailLines = carry; if (m_beforeTracker[i].getSize() < carry) m_beforeTracker[i].setSize(carry); #if DEBUG_BLOCKS @@ -112,7 +112,7 @@ SearchEngineError SearchEngine::mergeScope(uint32_t *filteredLines) #endif } else if (headLines < 0) { carry = (linesLeft > 0)? tailLines - linesLeft : (tailLines > 0)? tailLines : 0; - m_blocks[i-1].borrowHeadLines = carry; + m_blocks[i-1].lendedHeadLines = carry; if (m_afterTracker[i-1].getSize() < carry) m_afterTracker[i-1].setSize(carry); #if DEBUG_BLOCKS @@ -193,7 +193,14 @@ extern "C" { return context->engine->getLine(lineNumber, lineInfo); } - + + SearchEngineError se_get_row_for_abs_line(struct SEContext* context, uint32_t absLine, uint32_t* row) { + if (! (context && context->engine)) + return InvalidContext; + + return context->engine->getRowForAbsLine(absLine, row); + } + bool se_is_filtered(struct SEContext* context) { if (! (context && context->engine)) return 0; @@ -219,11 +226,15 @@ extern "C" { return context->engine->setScope(before, after); } - SearchEngineError se_set_pattern(struct SEContext* context, const char* pattern) { + SearchEngineError se_set_pattern(struct SEContext* context, const char* pattern, char* error) { if (! (context && context->engine)) return InvalidContext; - return context->engine->setPattern(pattern); + if (error) { + memset(error, 0, MAX_ERROR_LENGTH + 1); + } + + return context->engine->setPattern(pattern, error); } SearchEngineError se_filter(struct SEContext* context, uint32_t blockIdx, struct SEBlockInfo* info) { diff --git a/PeculiarLog/SearchEngine/SearchEngine.hpp b/PeculiarLog/SearchEngine/SearchEngine.hpp index 720ad21..68f81e2 100644 --- a/PeculiarLog/SearchEngine/SearchEngine.hpp +++ b/PeculiarLog/SearchEngine/SearchEngine.hpp @@ -36,7 +36,8 @@ extern "C" { static const uint32_t MAX_BLOCK_COUNT = 40; static const uint32_t MAX_SCOPE_BEFORE = 10; static const uint32_t MAX_SCOPE_AFTER = 10; - + static const uint32_t MAX_ERROR_LENGTH = 64; + typedef CF_ENUM(int, SearchEngineError) { NoError, BadArgument, @@ -78,9 +79,10 @@ extern "C" { SearchEngineError se_fetch(struct SEContext* context, uint32_t blockIdx, struct SEBlockInfo* info); SearchEngineError se_merge_scope(struct SEContext* context, uint32_t* filteredLines); SearchEngineError se_get_line(struct SEContext* context, uint32_t lineNumber, struct SELineInfo* lineInfo); + SearchEngineError se_get_row_for_abs_line(struct SEContext* context, uint32_t absLine, uint32_t* row); bool se_is_filtered(struct SEContext* context); SearchEngineError se_set_literal(struct SEContext* context, const char* literal); - SearchEngineError se_set_pattern(struct SEContext* context, const char* pattern); + SearchEngineError se_set_pattern(struct SEContext* context, const char* pattern, char* error); SearchEngineError se_set_ignore_case(struct SEContext* context, bool ignoreCase); SearchEngineError se_set_scope(struct SEContext* context, uint32_t before, uint32_t after); SearchEngineError se_filter(struct SEContext* context, uint32_t blockIdx, struct SEBlockInfo* info); @@ -110,9 +112,10 @@ class SearchEngine { virtual void close(); virtual SearchEngineError getLine(uint32_t number, SELineInfo* lineInfo) = 0; + virtual SearchEngineError getRowForAbsLine(uint32_t absLine, uint32_t* row) = 0; bool isFiltered(); - virtual SearchEngineError setPattern(const char* pattern) = 0; + virtual SearchEngineError setPattern(const char* pattern, char* error) = 0; virtual SearchEngineError setIgnoreCase(bool ignoreCase) = 0; virtual SearchEngineError setScope(uint32_t before, uint32_t after) = 0; virtual SearchEngineError filter(uint32_t blockIdx, SEBlockInfo* info) = 0; @@ -128,16 +131,16 @@ class SearchEngine { // optimizations struct SEBlock { - bool active; - uint64_t byteOffset; - uint32_t lines; - uint32_t filteredLines; - uint32_t scopeLines; - int32_t headLines; - int32_t tailLines; - int32_t borrowHeadLines; - int32_t borrowTailLines; - uint64_t size; + bool active; // block is in use + uint64_t byteOffset; // block start address within the file + uint32_t lines; // total number of lines + uint32_t filteredLines; // total number of pattern matching lines + uint32_t scopeLines; // total number of scope lines + int32_t headLines; // number of spare lines at the beginning of a block + int32_t tailLines; // number of spare lines at the end of a block + int32_t lendedHeadLines; // number of lines to lend to the head of next block + int32_t lendedTailLines; // number of lines to lend to the tail of previous block + uint64_t size; // block size }; SEBlock m_blocks[MAX_BLOCK_COUNT] = {0}; diff --git a/PeculiarLog/SearchEngine/SearchEngine.swift b/PeculiarLog/SearchEngine/SearchEngine.swift index d714f7d..6c6bc7c 100644 --- a/PeculiarLog/SearchEngine/SearchEngine.swift +++ b/PeculiarLog/SearchEngine/SearchEngine.swift @@ -13,7 +13,6 @@ class SearchEngine { struct ScopeBlocks { var head: Int32 = 0 var tail: Int32 = 0 - var borrow: Int32 = 0 } private var context = SEContext() @@ -119,6 +118,15 @@ class SearchEngine { return (String(bytesNoCopy: UnsafeMutableRawPointer(mutating:lineInfo.line), length:Int(lineInfo.length), encoding:.ascii, freeWhenDone: false)!, Int(lineInfo.number), lineInfo.scope, newWidth) } + func getRowForAbsLine(_ absLine: Int) -> Int { + var row : UInt32 = 0 + guard se_get_row_for_abs_line(&context, UInt32(absLine), &row) == .NoError else { + print("[!] unable to get row for line number \(absLine)") + return -1 + } + return Int(row) + } + func setIgnoreCase(_ ignoreCase: Bool) -> Bool { guard se_set_ignore_case(&context, ignoreCase) == .NoError else { print("[!] unable to set ignore case") @@ -138,13 +146,15 @@ class SearchEngine { return true; } - func setPattern(_ pattern: String) -> Bool { - guard se_set_pattern(&context, pattern) == .NoError else { + func setPattern(_ pattern: String) -> (Bool, String) { + let cError = UnsafeMutablePointer.allocate(capacity: Int(MAX_ERROR_LENGTH) + 1) + guard se_set_pattern(&context, pattern, cError) == .NoError else { print("[!] unable to set pattern") - return false + let error = String(cString: cError) + return (false, error) } - return true; + return (true, "") } func filter() -> Bool { diff --git a/PeculiarLog/SettingsViewController.swift b/PeculiarLog/SettingsViewController.swift index 0ce91f1..ed79955 100644 --- a/PeculiarLog/SettingsViewController.swift +++ b/PeculiarLog/SettingsViewController.swift @@ -270,16 +270,3 @@ extension SettingsViewController: NSTextFieldDelegate { } #endif } - -// MARK: - NumberFormatter customization - -class ScopeFormatter: NumberFormatter { - override func isPartialStringValid(_ partialString: String, newEditingString newString: AutoreleasingUnsafeMutablePointer?, errorDescription error: AutoreleasingUnsafeMutablePointer?) -> Bool { - let characterSet = NSMutableCharacterSet() - characterSet.formUnion(with: NSCharacterSet.decimalDigits) - if (partialString.rangeOfCharacter(from: characterSet.inverted) != nil) { - return false - } - return true - } -} diff --git a/PeculiarLog/ViewController.swift b/PeculiarLog/ViewController.swift index 3795961..cd966e8 100644 --- a/PeculiarLog/ViewController.swift +++ b/PeculiarLog/ViewController.swift @@ -13,21 +13,31 @@ import Cocoa class ViewController: NSViewController { @IBOutlet var patternField: NSTextField! + @IBOutlet var navigateField: NSTextField! + @IBOutlet var errorInfo: NSTextField! @IBOutlet var centerStatus: NSTextField! @IBOutlet var settingsButton: NSButton! @IBOutlet var beforeAfter: NSTextField! @IBOutlet var ignoreCase: NSTextField! + @IBOutlet var mainPanel: NSStackView! var splitViewController: SplitViewController! var logViewController: LogViewController! var settingsViewController: SettingsViewController! + var errorTimer: Timer? + override func viewDidLoad() { super.viewDidLoad() + errorInfo.textColor = NSColor.red + // Do any additional setup after loading the view. + errorInfo.isHidden = true ignoreCase.isHidden = true beforeAfter.isHidden = true + + mainPanel.arrangedSubviews[1].isHidden = true } override var representedObject: Any? { @@ -59,7 +69,27 @@ class ViewController: NSViewController { updateStatus() // set center status - centerStatus.stringValue = "\(engine.lineCount) of \(engine.totalLines) lines displayed, \(engine.totalBytesString) total" + centerStatus.stringValue = "\(engine.lineCount) of \(engine.totalLines) lines displayed" + let selected = logViewController.tableView.numberOfSelectedRows + if selected > 1 { + centerStatus.stringValue += " (\(selected) selected)" + } + centerStatus.stringValue += ", \(engine.totalBytesString) total" + + let mainMenu = NSApplication.shared.mainMenu! + let subMenu = mainMenu.item(withTitle: "File")?.submenu + + // handle menu + if engine.isFiltered { + subMenu?.item(withTitle: "Export Filtered")?.isEnabled = true + } else { + subMenu?.item(withTitle: "Export Filtered")?.isEnabled = false + } + if selected > 1 { + subMenu?.item(withTitle: "Export Selected")?.isEnabled = true + } else { + subMenu?.item(withTitle: "Export Selected")?.isEnabled = false + } } } @@ -69,6 +99,7 @@ class ViewController: NSViewController { settingsViewController = splitViewController.splitViewItems[0].viewController as? SettingsViewController settingsViewController.delegate = self logViewController = splitViewController.splitViewItems[1].viewController as? LogViewController + logViewController.delegate = self } } @@ -76,10 +107,37 @@ class ViewController: NSViewController { splitViewController.splitViewItems[0].animator().isCollapsed = !splitViewController.splitViewItems[0].isCollapsed } + @IBAction func gotoAbsoluteLineMenu(_ sender: Any) { + self.navigateField.window?.makeFirstResponder(self.navigateField) + + NSAnimationContext.runAnimationGroup({context in + context.duration = 0.25 + context.allowsImplicitAnimation = true + self.mainPanel.arrangedSubviews[1].animator().isHidden = false + }, completionHandler: { + }) + } + + @IBAction func copyAbsoluteLineNumber(_ sender: Any) { + guard let engine = representedObject as? SearchEngine else { return } + guard logViewController.tableView.numberOfSelectedRows > 0 else { return } + + let indexSet = logViewController.tableView.selectedRowIndexes + let rowIndex = indexSet.first! + let lineInfo = engine.getLine(rowIndex) + + let pasteBoard = NSPasteboard.general + pasteBoard.clearContents() + pasteBoard.setString(String(lineInfo.number), forType:NSPasteboard.PasteboardType.string) + } + func updateStatus() { // set center status guard let engine = representedObject as? SearchEngine else { return } + let mainMenu = NSApplication.shared.mainMenu! + let subMenu = mainMenu.item(withTitle: "File")?.submenu + let before = settingsViewController.scopeBefore let after = settingsViewController.scopeAfter var status = "" @@ -97,7 +155,62 @@ class ViewController: NSViewController { beforeAfter.isHidden = true } - centerStatus.stringValue = "\(engine.lineCount) of \(engine.totalLines) lines displayed, \(engine.totalBytesString) total" + // set center status + centerStatus.stringValue = "\(engine.lineCount) of \(engine.totalLines) lines displayed" + let selected = logViewController.tableView.numberOfSelectedRows + if selected > 1 { + centerStatus.stringValue += " (\(selected) selected)" + } + centerStatus.stringValue += ", \(engine.totalBytesString) total" + + // handle menu + if engine.isFiltered { + subMenu?.item(withTitle: "Export Filtered")?.isEnabled = true + } else { + subMenu?.item(withTitle: "Export Filtered")?.isEnabled = false + } + if selected > 1 { + subMenu?.item(withTitle: "Export Selected")?.isEnabled = true + } else { + subMenu?.item(withTitle: "Export Selected")?.isEnabled = false + } + } + + func setError(_ string: String) { + // set regexp error + errorInfo.stringValue = string + errorInfo.isHidden = false + + if (errorTimer != nil) { + errorTimer?.invalidate() + } + + errorTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in + self.errorInfo.stringValue = "" + self.errorInfo.isHidden = true + } + } + + func filteredContent(_ fileHandle: FileHandle) { + guard let engine = representedObject as? SearchEngine else { return } + + let filteredRows = logViewController.tableView.numberOfRows + for rowIndex in 0..=5.0) func controlTextDidChange(_ obj: Notification) { - logViewController.filterLog(with:patternField.stringValue, matchColor: settingsViewController.matchColor, scopeColor: settingsViewController.scopeColor) - updateStatus() + controlTextDidChangeCommon(obj) + } + + func controlTextDidEndEditing(_ obj: Notification) { + controlTextDidEndEditingCommon(obj) } #else override func controlTextDidChange(_ obj: Notification) { - logViewController.filterLog(with:patternField.stringValue, matchColor: settingsViewController.matchColor, scopeColor: settingsViewController.scopeColor) - updateStatus() + controlTextDidChangeCommon(obj) + } + + override func controlTextDidEndEditing(_ obj: Notification) { + controlTextDidEndEditingCommon(obj) } #endif } @@ -143,6 +304,19 @@ extension ViewController: SettingsDelegate { } } +// MARK: - LogView delegate + +extension ViewController: LogViewDelegate { + func patternCompilationError(_ error: String) { + setError(error) + } + + func selectionChanged() { + updateStatus() + } +} + + // MARK: - SplitView customization class SplitViewController: NSSplitViewController { @@ -156,3 +330,16 @@ class CustomSplitView: NSSplitView { get { return 0.0 } } } + +// MARK: - NumberFormatter customization + +class CustomNumberFormatter: NumberFormatter { + override func isPartialStringValid(_ partialString: String, newEditingString newString: AutoreleasingUnsafeMutablePointer?, errorDescription error: AutoreleasingUnsafeMutablePointer?) -> Bool { + let characterSet = NSMutableCharacterSet() + characterSet.formUnion(with: NSCharacterSet.decimalDigits) + if (partialString.rangeOfCharacter(from: characterSet.inverted) != nil) { + return false + } + return true + } +} diff --git a/README.md b/README.md index 32df89a..70f5706 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ You can find most recent compiled version [here](https://github.com/alexhude/Pec #### Prerequisites -Since **PeculiarLog** is based on Intel Hyperscan engine it is expecting headers to be available at `/usr/local/opt/hyperscan/include/hs` and **libhs.a** located at `/usr/local/lib` +Since **PeculiarLog** is based on **Intel Hyperscan** engine it is expecting headers to be available at `/usr/local/opt/hyperscan/include/hs` and **libhs.a** located at `/usr/local/lib` Brew is the easiest way to install Hyperscan engine. @@ -55,8 +55,12 @@ The version of PCRE used to validate Hyperscan’s interpretation of this syntax is 8.41 or above. ``` +Hit enter to get pattern compilation error in the bottom left corner of a status bar. + #### Shortcuts +There is a hint on the right side of a settings panel for the most useful shortcuts. + ``` ⇧ ⌃ ↑ - increase 'before' scope ⇧ ⌃ ↓ - decrease 'before' scope @@ -66,7 +70,20 @@ syntax is 8.41 or above. ⇧ ⌘ l - toggle line numbers ``` -There is also a hint on the right side of a settings panel. +Some other line related shortcuts available from **View** menu. + +``` +⌃ ⌘ l - copy current line number + ⌘ l - go to line +``` + +#### Export + +There are several ways to export results: + +- select lines right in the application window and copy to clipboard +- save filtered results to a separate file using **File/Export Filtered** menu +- save selected lines to a separate file using **File/Export Selected** menu ### Demo diff --git a/Resources/demo.gif b/Resources/demo.gif index 9d352c7..ed5498f 100644 Binary files a/Resources/demo.gif and b/Resources/demo.gif differ