diff --git a/FiBu-Konto.ahk b/FiBu-Konto.ahk new file mode 100644 index 0000000..7166d77 --- /dev/null +++ b/FiBu-Konto.ahk @@ -0,0 +1,570 @@ +/* + +Dieses Script vereinfacht das Buchen mit der Sherpa, indem FiBu-Konten +schnell gesucht und ausgewählt werden können. Das Suchfenster wird dafür +mit der Tastenkombination STRG+ALT+k (K wie Konto) geöffnet. + +Es basiert auf AutoHotKey v2 und ist deshalb nur für Windows verfügbar. + +Installation: +1. AutoHotKey v2 herunterladen & installieren: https://www.autohotkey.com/ +2. Dieses Script (FiBu-Konto.ahk) z. B. in den AutoHotKey-Ordner ablegen + (Dokumente -> AutoHotKey) +3. FiBu-Konto.ahk doppelklicken (ein neues System-Icon öffnet sich) + +Einrichtung (Kontenplan aus Summen- und Saldenliste importieren): +- In den Sherpa-Einstellungen sicherstellen, dass der Export mit UTF-8- + Zeichensatz geschieht (Allgemein -> Finanzbuchhaltung -> Exportoptionen). + Diese Einstellung muss für jeden Kontext/Gliederung gesetzt werden. +- In Sherpa: Finanzbuchhaltung -> Summen- und Saldenliste -> Anzeigen + -> "Nur bebuchte Konten" ausschalten (alle anzeigen) + -> CSV-Datei exportieren +- System-Icon rechtsklicken, "Summen- und Saldenliste importieren", + Datei auswählen +- Die SuSa-Datei kann jetzt gelöscht werden + +Benutzung: +- Cursor in der Sherpa in das Soll-/Haben-Eingabefeld setzen +- STRG+ALT+k öffnet das Fenster +- Enter-Taste schreibt das 4-stellige FiBu-Konto in das Soll-/Haben-Feld +- Escape-Taste schließt das Fenster + +Tipps: +- Die Suche findet alle Konten, die die Zeichen in der richtigen Reihenfolge + enthalten, diese müssen aber nicht direkt aufeinander folgen. Ein Beispiel: + "vblkr" findet "Verbindlichkeiten an Kreisverbände" +- Das Script kann automatisch bei Systemstart ausgeführt werden, sodass das + Suchfenster immer verfügbar ist: Rechtsklick auf das System-Icon + -> "Bei Systemstart starten" +- Das Script kann nach der Eingabe des FiBu-Kontos Strg+S drücken, z. B. um + den Buchungsstapel regelmäßig zu speichern +- Außerdem kann das FiBu-Konto vorgelesen werden, um eine akustische Rück- + meldung zu erhalten und dadurch Fehler zu vermeiden + +Lizenz: GPL v3 +Ansprechperson: Florian Kürsch +Version: 0.1 +Stand: 2024-10-23 +*/ + +#Requires AutoHotkey v2.0 + +FileEncoding("UTF-8") +autostartShortcutPath := A_Startup . "\FiBu-Konto.lnk" +configDir := A_AppData . "\AHK-FiBu-Konto" +ktoListPath := configDir . "\fibu-konten.txt" +autosaveConfigPath := configDir . "\autosave_enabled" +voiceConfigPath := configDir . "\voice_enabled" + +DirCreate(configDir) + +defaultKtoList := [ + "0000 Importiere die FiBu-Konten über die SuSa-Liste" +] + +isActive := false + +voice := ComObject("SAPI.SpVoice") +voice.Volume := 100 ; Volume from 0 to 100 +voice.Rate := 0 ; Rate from -10 (slow) to 10 (fast) + +A_TrayMenu.Delete() +A_TrayMenu.Insert("1&", "Konten-Suche öffnen (Strg+Alt+k)", (*) => open()) +A_TrayMenu.Insert("2&") ; separator +A_TrayMenu.Insert("3&", "Summen- && Saldenliste importieren...", (*) => import_susa_file()) +A_TrayMenu.Insert("4&", "Beim Systemstart starten", toggle_autostart) +if (is_autostart_enabled()) { + A_TrayMenu.Check("4&") +} +A_TrayMenu.Insert("5&", "Nach Eingabe des Kontos Strg+S drücken", toggle_autosave) +if (is_autosave_enabled()) { + A_TrayMenu.Check("5&") +} +A_TrayMenu.Insert("6&", "Konto vorlesen", toggle_voice) +if (is_voice_enabled()) { + A_TrayMenu.Check("6&") +} +A_TrayMenu.Insert("7&") ; separator +A_TrayMenu.Insert("8&", "Beenden", (*) => ExitApp()) + +myGui := Gui("+AlwaysOnTop -Caption +ToolWindow -MinimizeBox", "FiBu-Konto auswählen") +myGui.SetFont("s10") +myGui.OnEvent("Escape", on_escape_pressed) +guiHwnd := myGui.Hwnd +OnMessage(0x0006, handle_activation) + +myEdit := myGui.Add("Edit", "w500 vMyEdit") +myEdit.OnEvent("Change", on_text_changed) +myListBox := myGui.Add("ListBox", "r15 w500 vmyListBox") +okButton := myGui.Add("Button", "Default w80", "OK") +okButton.OnEvent("Click", on_okBtn_clicked) + +ktoList := read_array_from_file(ktoListPath, defaultKtoList) +apply_list(ktoList) + +if (not is_kto_list_imported()) { + import_susa_file() +} + + +^!k:: ; Ctrl+Alt+k +{ + open() +} + +on_escape_pressed(*) { + close() +} + +handle_activation(wParam, lParam, msg, hwnd) { + if (hwnd = guiHwnd && !wParam) { + on_focus_lost() + } +} + +on_focus_lost() { + close() +} + +on_okBtn_clicked(*) { ; auch wenn Enter gedrückt wird + if (myListBox.Value = 0) { + return + } + kto := SubStr(myListBox.Text, 1, 4) ; FiBu-Konten sind 4-stellig + close() + + if(is_voice_enabled()) { + textToSpeak := RegExReplace(myListBox.Text, "^\d{4}\s+") + textToSpeak := RegExReplace(textToSpeak, "U)\s*\(.*\)$") ; U) ungreedy + textToSpeak := textToSpeak . ", " . RegExReplace(kto, "(.)", "$1 ") + SetTimer () => say(textToSpeak), -1 ; async + } + + SendText(kto) + + if(is_autosave_enabled()) { + SendInput "^s" ; ctrl + s + } +} + +open() { + global isActive + isActive := true + show_gui_on_active_monitor() + myEdit.Focus() +} + +close() { + global isActive + myGui.Hide() + isActive := false +} + +; Wir wollen aus dem Textfeld heraus Elemente aus der Liste auswählen. +; Deshalb registrieren wir einen globalen Hotkey, der nur ausgeführt +; wird, wenn das Fenster aktiv ist (es gibt kein KeyPress-Event o. Ä. +; für das Textfeld). +#HotIf isActive +Up:: { + up_by(1) +} +PgUp:: { + up_by(10) +} +up_by(offset) { + val := myListBox.Value - offset + if (val < 1) { + val := 1 + } + myListBox.Value := val +} +Down:: { + down_by(1) +} +PgDn:: { + down_by(10) +} +down_by(offset) { + val := myListBox.Value + offset + len := ControlGetItems(myListBox).Length + if (val > len) { + val := len + } + myListBox.Value := val +} +#HotIf + + +on_text_changed(*) { + do_search_delayed() +} + + +searchUpdateTimer := do_search + +do_search_delayed() { + global searchUpdateTimer + if (searchUpdateTimer != 0) { + SetTimer searchUpdateTimer, 0 ; clear + } + searchUpdateTimer := do_search + SetTimer(searchUpdateTimer, -50) ; 50 ms delay / negative: exec once +} + + +do_search() { + searchText := myEdit.Text + filteredList := filter_list(ktoList, searchText) + apply_list(filteredList) + select_best_match(filteredList, searchText) +} + + +filter_list(originalList, searchText) { + filteredList := [] + Loop originalList.Length { + if is_subsequence(originalList[A_Index], searchText) { + filteredList.Push(originalList[A_Index]) + } + } + return filteredList +} + +apply_list(newList) { + myListBox.Opt("-Redraw") + myListBox.Delete() + if(newList.Length = 0) { + okButton.Enabled := false + myListBox.Opt("+Redraw") + return + } + okButton.Enabled := true + myListBox.Add(newList) + myListBox.Opt("+Redraw") +} + +select_best_match(filteredList, searchText) { + if (filteredList.Length = 0) { + return + } + if (searchText = "") { + myListBox.Choose(1) + return + } + exact_match := find_best_exact_match(filteredList, searchText) + if (exact_match > 0) { + myListBox.Choose(exact_match) + return + } + grouped_match := find_best_grouped_match(filteredList, searchText) + if (grouped_match > 0) { + myListBox.Choose(grouped_match) + return + } + subsequence_match := find_best_subsequence_match(filteredList, searchText) + if (subsequence_match > 0) { + myListBox.Choose(subsequence_match) + return + } + ;myListBox.Choose(1) ; fallback +} + + +find_best_exact_match(list, searchText) { + /* + Beispiel: Suche nach "43" + - "4243 ..." + - "4300 ..." <= Selektion, weil die 43 am Anfang steht + - "4301 ..." + */ + return find_best_match(list, searchText, match_substring) +} + +match_substring(sourceText, searchText) { + startPos := InStr(sourceText, searchText, false) ; false = case-insensitive + length := startPos = 0 ? 0 : StrLen(searchText) + return {startPos: startPos, length: length} +} + +find_best_grouped_match(list, searchText) { + /* + Beispiel: Suche nach "pa ra" + - "1803 Forderungen Pfand (Ford Pfand)" + - "4301 PA Allg Raummiete & Nebenkosten (PA Allg MietRaum)" <= Selektion, weil "ra" in einem neuen Wort beginnt + - "4321 PA PT Raummiete & Nebenkosten (PA PT MietRaum)" <= nicht selektiert, obwohl der Match kürzer ist + */ + return find_best_match(list, searchText, match_group) + } + + match_group(sourceText, searchText) { + pattern := "iU)" . RegExReplace(searchText, "\s+", ".* ") + startPos := RegExMatch(sourceText, pattern, &match) + return {startPos: startPos, length: 1} ; Länge wird ignoriert + } + +find_best_subsequence_match(list, searchText) { + /* + Beispiel: Suche nach "para" + Hier wird die 3. Zeile selektiert, weil "p" weit links steht und das Match früher Endet als in der 2. Zeile + - "1650 Sparbücher (Sparbuch)" + - "4100 Personalausgaben (Ausg Personal) <= "p" steht an der gleichen Position, aber das Match ist deutlich länger + - "4301 PA Allg Raummiete & Nebenkosten (PA Allg MietRaum)" + */ + return find_best_match(list, searchText, match_subsequence) +} + +match_subsequence(sourceText, searchText) { + pattern := "iU)" . RegExReplace(searchText, "(.)", "$1.*") + ; i: case-insensitive / U: ungreedy + startPos := RegExMatch(sourceText, pattern, &match) + length := startPos = 0 ? 0 : match.Len + return {startPos: startPos, length: length} +} + +find_best_match(list, searchText, match_func) { + if (list.Length = 0 or searchText = "") { + return 0 + } + bestListIdx := 0 + bestListIdxMatchPos := 0 ; 0 = not found / fist index = 1 + bestListIdxMatchLength := 0 + loop list.Length { + row := list[A_Index] + matchInfo := match_func(row, searchText) + matchPos := matchInfo.startPos + matchLength := matchInfo.length + if (matchPos = 0) { + continue + } + if (bestListIdxMatchPos = 0 + or matchPos < bestListIdxMatchPos + or (matchPos = bestListIdxMatchPos and matchLength < bestListIdxMatchLength)) { + bestListIdx := A_Index + bestListIdxMatchPos := matchPos + bestListIdxMatchLength := matchLength + } + } + return bestListIdx +} + +is_subsequence(sourceText, searchText) { + return match_subsequence(sourceText, searchText).startPos > 0 ? true : false +} + +import_susa_file() { + MsgBox(" + ( + Das Suchfenster benötigt eine Liste aller FiBu-Konten. Am einfachsten geht das über die Summen- und Saldenliste: + + 1. In den Sherpa-Einstellungen sicherstellen, dass der Export mit UTF-8- + Zeichensatz geschieht (Allgemein => Finanzbuchhaltung => Exportoptionen). + Hinweis: Diese Einstellung gilt nur für die aktuelle Gliederung. + + 2. In Sherpa: Finanzbuchhaltung => Summen- und Saldenliste => Anzeigen + -> "Nur bebuchte Konten" ausschalten (alle anzeigen) + -> CSV-Datei exportieren + + Wenn die SuSa-Liste bereit ist, klicke bitte OK. + )", "Summen- und Saldenliste importieren") + + selectedFile := FileSelect("1", "", "Summen- und Saldenliste auswählen (UTF-8)", "CSV-Dateien (*.csv)") + ; "1": File Must Exist + if (selectedFile = "") { + return + } + + newKtoList := create_kto_list_from_susa_file(selectedFile) + write_array_to_file(newKtoList, ktoListPath) + + global ktoList + ktoList := newKtoList + apply_list(ktoList) + + MsgBox(" + ( + FiBu-Konten erfolgreich importiert. Du kannst die SuSa-Datei jetzt löschen. + + Das Suchfenster kannst du mit STRG+ALT+k öffnen, nachdem du den Cursor in ein Textfeld gesetzt hast. + )", "Import erfolgreich") +} + +is_kto_list_imported() { + return FileExist(ktoListPath) != "" +} + +create_kto_list_from_susa_file(susaFile) { + newKtoList := [] + + Loop read, susaFile { + if (A_Index = 1) { + Continue ; erste Zeile überspringen + } + lineNumber := A_Index + ; workaround: das AutoHotKey CSV-Parsing erwartet Komma, Sherpa exportiert Semikolon + ; todo: prüfen, was mit Kommata in Feldern passiert + line := StrReplace(A_LoopReadLine, ";", ",") + newKtoLine := "" + Loop parse, line, "CSV" { + if (A_Index = 4) { + break ; nur die ersten 3 Spalten sind für uns interessant + } + field := A_LoopField + if (A_Index = 3) { ; Kurzname + field := "(" . field . ")" + } + field := Trim(field) + newKtoLine := newKtoLine . " " . field + } + newKtoList.Push(Trim(newKtoLine)) + } + + return newKtoList +} + +write_array_to_file(myArray, filePath) { + file := FileOpen(filePath, "w") + for line in myArray { + file.WriteLine(line) + } + file.Close() +} + +read_array_from_file(filePath, defaultArray) { + if ( FileExist(filePath) = "" ) { + return defaultArray + } + myArray := [] + Loop Read, filePath { + myArray.Push(A_LoopReadLine) + } + return myArray +} + +toggle_autostart(itemName, itemPos, myMenu) { + if (is_autostart_enabled()) { + FileDelete(autostartShortcutPath) + myMenu.Uncheck(itemName) + } else { + FileCreateShortcut(A_ScriptFullPath, autostartShortcutPath) + myMenu.Check(itemName) + } +} + +is_autostart_enabled() { + return FileExist(autostartShortcutPath) != "" +} + +toggle_autosave(itemName, itemPos, myMenu) { + if (is_autosave_enabled()) { + FileDelete(autosaveConfigPath) + myMenu.Uncheck(itemName) + } else { + FileAppend "", autosaveConfigPath + myMenu.Check(itemName) + } +} + +is_autosave_enabled() { + return FileExist(autosaveConfigPath) != "" +} + + +toggle_voice(itemName, itemPos, myMenu) { + if (is_voice_enabled()) { + FileDelete(voiceConfigPath) + myMenu.Uncheck(itemName) + } else { + FileAppend "", voiceConfigPath + myMenu.Check(itemName) + } +} + +is_voice_enabled() { + return FileExist(voiceConfigPath) != "" +} + + +show_gui_on_active_monitor() { + activeWin := WinExist("A") + if (not activeWin) { + myGui.Show() + return + } + WinGetPos(&winX, &winY, &winWidth, &winHeight, activeWin) + winCenterX := winX + winWidth // 2 + winCenterY := winY + winHeight // 2 + + myGui.Show() + ; GetPos ist erst verfügbar, wenn das Fenster sichtbar ist + myGui.GetPos(&guiX, &guiY, &guiWidth, &guiHeight) + + monitorIndex := get_monitor_index_from_window(winCenterX, winCenterY) + monitorInfo := get_monitor_info(monitorIndex) + guiX := monitorInfo.left + (monitorInfo.right - monitorInfo.left) // 2 - guiWidth // 2 + guiY := monitorInfo.top + (monitorInfo.bottom - monitorInfo.top) // 2 - guiHeight // 2 + + myGui.Show(Format("x{} y{}", guiX, guiY)) +} + +get_monitor_index_from_window(x, y) { + monitorCount := MonitorGetCount() + Loop monitorCount { + monitorInfo := get_monitor_info(A_Index) + if (x >= monitorInfo.left && x < monitorInfo.right && y >= monitorInfo.top && y < monitorInfo.bottom) { + return A_Index + } + } + return 1 +} + +get_monitor_info(monitorIndex) { + MonitorGet(monitorIndex, &left, &top, &right, &bottom) + MonitorGetWorkArea(monitorIndex, &workLeft, &workTop, &workRight, &workBottom) + return {left: workLeft, top: workTop, right: workRight, bottom: workBottom} +} + +say(textToSpeak) { + ; todo: diese Liste als CSV-Datei speichern/laden + voice.Skip("Sentence", 1) + textToSpeak := StrReplace(textToSpeak, "Pers Net ", "Personal Netto: ", true) + textToSpeak := StrReplace(textToSpeak, "Pers SV ", "Personal Sozialversicherung: ", true) + textToSpeak := StrReplace(textToSpeak, "Pers FA ", "Personal Finanzamt: ", true) + textToSpeak := StrReplace(textToSpeak, "Pers ", "Personal ", true) + textToSpeak := StrReplace(textToSpeak, "GB Inv ", "Geschäftsbetrieb Inventar: ", true) + textToSpeak := StrReplace(textToSpeak, "GB Bü ", "Geschäftsbetrieb Büro: ", true) + textToSpeak := StrReplace(textToSpeak, "GB DL ", "Geschäftsbetrieb Dienstleistungen: ", true) + textToSpeak := StrReplace(textToSpeak, "GB Sonst ", "Geschäftsbetrieb Sonstiges: ", true) + textToSpeak := StrReplace(textToSpeak, "GB ", "Geschäftsbetrieb ", true) + textToSpeak := StrReplace(textToSpeak, "PA Allg ", "Politische Arbeit allgemein: ", true) + textToSpeak := StrReplace(textToSpeak, "PA PT ", "Politische Arbeit Parteitage: ", true) + textToSpeak := StrReplace(textToSpeak, "PA VA ", "Politische Arbeit Veranstaltungen: ", true) + textToSpeak := StrReplace(textToSpeak, "PA Gre ", "Politische Arbeit Gremien: ", true) + textToSpeak := StrReplace(textToSpeak, "PA ", "Politische Arbeit ", true) + textToSpeak := StrReplace(textToSpeak, "WK EU ", "Wahlkampf Europa-Wahl: ", true) + textToSpeak := StrReplace(textToSpeak, "WK BTW ", "Wahlkampf Bundestagswahl: ", true) + textToSpeak := StrReplace(textToSpeak, "WK Bü ", "Wahlkampf Bürgerschaftswahl: ", true) + textToSpeak := StrReplace(textToSpeak, "WK BV ", "Wahlkampf Bezirkswahl: ", true) + textToSpeak := StrReplace(textToSpeak, "WK ", "Wahlkampf ", true) + textToSpeak := StrReplace(textToSpeak, "VZ GB ", "Verrechnungszuschuss eingehend, Geschäftsbetrieb: ", true) + textToSpeak := StrReplace(textToSpeak, "VZ PA ", "Verrechnungszuschuss eingehend, Politische Arbeit: ", true) + textToSpeak := StrReplace(textToSpeak, "VZ WK ", "Verrechnungszuschuss eingehend, Wahlkampf: ", true) + textToSpeak := StrReplace(textToSpeak, "VZ ", "Verrechnungszuschuss eingehend ", true) + textToSpeak := StrReplace(textToSpeak, "vz GB ", "Verrechnungszuschuss ausgehend, Geschäftsbetrieb: ", true) + textToSpeak := StrReplace(textToSpeak, "vz PA ", "Verrechnungszuschuss ausgehend, Politische Arbeit: ", true) + textToSpeak := StrReplace(textToSpeak, "vz WK ", "Verrechnungszuschuss ausgehend, Wahlkampf: ", true) + textToSpeak := StrReplace(textToSpeak, "vz ", "Verrechnungszuschuss ausgehend ", true) + textToSpeak := StrReplace(textToSpeak, "EZ ", "Echter Zuschuss eingehend ", true) + textToSpeak := StrReplace(textToSpeak, "ez ", "Echter Zuschuss ausgehend ", true) + textToSpeak := StrReplace(textToSpeak, "GBV", "Geschäftsbesorgungsvertrag", true) + textToSpeak := StrReplace(textToSpeak, "auß ", "außerhalb ", true) + textToSpeak := StrReplace(textToSpeak, "HH", "Hamburg", true) + textToSpeak := StrReplace(textToSpeak, "BV", "Bundesverband", true) + textToSpeak := StrReplace(textToSpeak, "LV", "Landesverband", true) + textToSpeak := StrReplace(textToSpeak, "KV", "Kreisverband", true) + textToSpeak := StrReplace(textToSpeak, "GJ", "Grüne Jugend", true) + textToSpeak := StrReplace(textToSpeak, "Gl.", "Gliederung", true) + textToSpeak := StrReplace(textToSpeak, "Fr ", "Fraktion ", true) + textToSpeak := StrReplace(textToSpeak, "FiBu", "Finanzbuchhaltung", true) + textToSpeak := StrReplace(textToSpeak, "USt", "Umsatzsteuer", true) + voice.Speak(textToSpeak, 0x1) +}