stundu 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ # CLI infrastructure module - configuration and i18n
2
+ from .config import (
3
+ get_config_path,
4
+ get_config_dir,
5
+ config_exists,
6
+ is_first_run,
7
+ load_config,
8
+ save_config,
9
+ get_config_value,
10
+ get_data_file,
11
+ ENV_CONFIG_FILE,
12
+ ENV_DATA_FILE,
13
+ DEFAULT_CONFIG_DIR,
14
+ DEFAULT_CONFIG_FILE,
15
+ DEFAULT_DATA_FILE,
16
+ )
17
+ from .i18n_setup import setup_i18n, t, get_locale
18
+
19
+ __all__ = [
20
+ "get_config_path",
21
+ "get_config_dir",
22
+ "config_exists",
23
+ "is_first_run",
24
+ "load_config",
25
+ "save_config",
26
+ "get_config_value",
27
+ "get_data_file",
28
+ "ENV_CONFIG_FILE",
29
+ "ENV_DATA_FILE",
30
+ "DEFAULT_CONFIG_DIR",
31
+ "DEFAULT_CONFIG_FILE",
32
+ "DEFAULT_DATA_FILE",
33
+ "setup_i18n",
34
+ "t",
35
+ "get_locale",
36
+ ]
cli/config.py ADDED
@@ -0,0 +1,182 @@
1
+ """Configuration management for stundu CLI."""
2
+
3
+ import os
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional, Any
7
+
8
+ # Environment variable names
9
+ ENV_CONFIG_FILE = "STUNDU_CONFIG"
10
+ ENV_DATA_FILE = "STUNDU_DATA"
11
+
12
+ # Default config location
13
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "stundu"
14
+ DEFAULT_CONFIG_FILE = "config.json"
15
+ DEFAULT_DATA_FILE = "stundu_data.csv"
16
+
17
+
18
+ def get_config_path() -> Path:
19
+ """Get the path to the config file.
20
+
21
+ Precedence: STUNDU_CONFIG env var > snap user dir > default location
22
+ """
23
+ env_path = os.environ.get(ENV_CONFIG_FILE)
24
+ if env_path:
25
+ return Path(env_path)
26
+ snap_common = os.environ.get("SNAP_USER_COMMON")
27
+ if snap_common:
28
+ return Path(snap_common) / DEFAULT_CONFIG_FILE
29
+ return DEFAULT_CONFIG_DIR / DEFAULT_CONFIG_FILE
30
+
31
+
32
+ def get_config_dir() -> Path:
33
+ """Get the path to the config directory."""
34
+ return get_config_path().parent
35
+
36
+
37
+ def config_exists() -> bool:
38
+ """Check if a config file exists."""
39
+ return get_config_path().exists()
40
+
41
+
42
+ def is_first_run() -> bool:
43
+ """Check if this is the first run (no config and no env vars)."""
44
+ # If env vars are set, it's not a first run
45
+ if os.environ.get(ENV_CONFIG_FILE) or os.environ.get(ENV_DATA_FILE):
46
+ return False
47
+ # If config file exists, it's not a first run
48
+ if config_exists():
49
+ return False
50
+ return True
51
+
52
+
53
+ def load_config() -> dict[str, Any]:
54
+ """Load configuration from config file.
55
+
56
+ Returns:
57
+ Configuration dictionary, empty dict if file doesn't exist or is invalid.
58
+ """
59
+ config_path = get_config_path()
60
+ if config_path.exists():
61
+ try:
62
+ return json.loads(config_path.read_text())
63
+ except (json.JSONDecodeError, IOError):
64
+ pass
65
+ return {}
66
+
67
+
68
+ def save_config(config: dict[str, Any]) -> None:
69
+ """Save configuration to config file.
70
+
71
+ Args:
72
+ config: Configuration dictionary to save
73
+ """
74
+ config_path = get_config_path()
75
+ config_dir = config_path.parent
76
+
77
+ # Create config directory if it doesn't exist
78
+ config_dir.mkdir(parents=True, exist_ok=True)
79
+
80
+ # Write config file
81
+ config_path.write_text(json.dumps(config, indent=2) + "\n")
82
+
83
+
84
+ VALID_STYLES = {
85
+ "plain",
86
+ "simple",
87
+ "github",
88
+ "grid",
89
+ "simple_grid",
90
+ "rounded_grid",
91
+ "heavy_grid",
92
+ "double_grid",
93
+ "fancy_grid",
94
+ "outline",
95
+ "simple_outline",
96
+ "rounded_outline",
97
+ "heavy_outline",
98
+ "double_outline",
99
+ "fancy_outline",
100
+ "presto",
101
+ "pretty",
102
+ "psql",
103
+ "pipe",
104
+ }
105
+
106
+ _LIGHT_STYLES = {
107
+ "simple_grid",
108
+ "rounded_grid",
109
+ "outline",
110
+ "simple_outline",
111
+ "rounded_outline",
112
+ }
113
+
114
+ _HEAVY_STYLES = {
115
+ "heavy_grid",
116
+ "double_grid",
117
+ "fancy_grid",
118
+ "heavy_outline",
119
+ "double_outline",
120
+ "fancy_outline",
121
+ }
122
+
123
+
124
+ def get_table_style() -> str:
125
+ """Get the configured table style, defaulting to 'simple'."""
126
+ return get_config_value("style", "simple")
127
+
128
+
129
+ def get_divider_char() -> str:
130
+ """Get the divider character matching the current table style.
131
+
132
+ Three tiers: ascii (-), light (─), heavy (━).
133
+ """
134
+ style = get_table_style()
135
+ if style in _LIGHT_STYLES:
136
+ return "─"
137
+ if style in _HEAVY_STYLES:
138
+ return "━"
139
+ return "-"
140
+
141
+
142
+ def get_config_value(key: str, default: Any = None) -> Any:
143
+ """Get a configuration value.
144
+
145
+ Args:
146
+ key: Configuration key
147
+ default: Default value if key doesn't exist
148
+
149
+ Returns:
150
+ Configuration value or default
151
+ """
152
+ config = load_config()
153
+ return config.get(key, default)
154
+
155
+
156
+ def get_data_file(cli_override: Optional[str] = None) -> str:
157
+ """Get the data file path.
158
+
159
+ Precedence: CLI override > env var > config file > default
160
+
161
+ Args:
162
+ cli_override: Path from CLI --data option
163
+
164
+ Returns:
165
+ Path to the data file
166
+ """
167
+ # 1. CLI override (highest priority)
168
+ if cli_override:
169
+ return cli_override
170
+
171
+ # 2. Environment variable
172
+ env_path = os.environ.get(ENV_DATA_FILE)
173
+ if env_path:
174
+ return env_path
175
+
176
+ # 3. Config file
177
+ config_value = get_config_value("data_file")
178
+ if config_value:
179
+ return config_value
180
+
181
+ # 4. Default
182
+ return DEFAULT_DATA_FILE
cli/formats.py ADDED
@@ -0,0 +1,179 @@
1
+ """Date and time formatting with Babel localization."""
2
+
3
+ from datetime import datetime, timedelta
4
+ from typing import Optional
5
+
6
+ from babel.dates import format_date, format_time, format_datetime
7
+ from babel import Locale, UnknownLocaleError
8
+
9
+ from cli.config import get_config_value
10
+
11
+ # Map our 2-letter codes to Babel locale codes
12
+ LOCALE_MAP = {
13
+ "en": "en_US",
14
+ "de": "de_DE",
15
+ "es": "es_ES",
16
+ "et": "et_EE",
17
+ "fr": "fr_FR",
18
+ "it": "it_IT",
19
+ "lt": "lt_LT",
20
+ "lv": "lv_LV",
21
+ "pl": "pl_PL",
22
+ "pt": "pt_PT",
23
+ "uk": "uk_UA",
24
+ }
25
+
26
+ # Default format styles (Babel format names: full, long, medium, short)
27
+ DEFAULT_DATE_FORMAT = "medium"
28
+ DEFAULT_TIME_FORMAT = "short"
29
+ DEFAULT_DATETIME_FORMAT = "medium"
30
+
31
+ # Module-level locale cache
32
+ _current_locale: Optional[str] = None
33
+ _babel_locale: Optional[str] = None
34
+
35
+
36
+ def set_format_locale(locale: str) -> None:
37
+ """Set the locale for date/time formatting.
38
+
39
+ Args:
40
+ locale: Two-letter language code (e.g., 'en', 'lv')
41
+ """
42
+ global _current_locale, _babel_locale
43
+ _current_locale = locale
44
+ _babel_locale = LOCALE_MAP.get(locale, "en_US")
45
+
46
+ # Validate the Babel locale
47
+ try:
48
+ Locale.parse(_babel_locale)
49
+ except UnknownLocaleError:
50
+ _babel_locale = "en_US"
51
+
52
+
53
+ def get_babel_locale() -> str:
54
+ """Get the current Babel locale string."""
55
+ return _babel_locale or "en_US"
56
+
57
+
58
+ def _get_format(format_type: str, default: str) -> str:
59
+ """Get format from config or return default.
60
+
61
+ Config can specify:
62
+ - Babel format names: "full", "long", "medium", "short"
63
+ - Custom patterns: "dd.MM.yyyy", "HH:mm", etc.
64
+
65
+ Args:
66
+ format_type: One of "date", "time", "datetime"
67
+ default: Default format to use
68
+
69
+ Returns:
70
+ Format string or Babel format name
71
+ """
72
+ formats = get_config_value("formats", {})
73
+ return formats.get(format_type, default)
74
+
75
+
76
+ def _is_babel_format(fmt: str) -> bool:
77
+ """Check if format is a Babel format name."""
78
+ return fmt in ("full", "long", "medium", "short")
79
+
80
+
81
+ def fmt_date(dt: Optional[datetime], format: Optional[str] = None) -> str:
82
+ """Format a date with localization.
83
+
84
+ Args:
85
+ dt: Datetime to format (None returns "-")
86
+ format: Override format (Babel name or pattern)
87
+
88
+ Returns:
89
+ Formatted date string
90
+ """
91
+ if dt is None:
92
+ return "-"
93
+
94
+ fmt = format or _get_format("date", DEFAULT_DATE_FORMAT)
95
+ locale = get_babel_locale()
96
+
97
+ if _is_babel_format(fmt):
98
+ return format_date(dt, format=fmt, locale=locale)
99
+ else:
100
+ # Custom pattern
101
+ return format_date(dt, format=fmt, locale=locale)
102
+
103
+
104
+ def fmt_time(dt: Optional[datetime], format: Optional[str] = None) -> str:
105
+ """Format a time with localization.
106
+
107
+ Args:
108
+ dt: Datetime to format (None returns "-")
109
+ format: Override format (Babel name or pattern)
110
+
111
+ Returns:
112
+ Formatted time string
113
+ """
114
+ if dt is None:
115
+ return "-"
116
+
117
+ fmt = format or _get_format("time", DEFAULT_TIME_FORMAT)
118
+ locale = get_babel_locale()
119
+
120
+ if _is_babel_format(fmt):
121
+ return format_time(dt, format=fmt, locale=locale)
122
+ else:
123
+ return format_time(dt, format=fmt, locale=locale)
124
+
125
+
126
+ def fmt_datetime(dt: Optional[datetime], format: Optional[str] = None) -> str:
127
+ """Format a datetime with localization.
128
+
129
+ Args:
130
+ dt: Datetime to format (None returns "-")
131
+ format: Override format (Babel name or pattern)
132
+
133
+ Returns:
134
+ Formatted datetime string
135
+ """
136
+ if dt is None:
137
+ return "-"
138
+
139
+ fmt = format or _get_format("datetime", DEFAULT_DATETIME_FORMAT)
140
+ locale = get_babel_locale()
141
+
142
+ if _is_babel_format(fmt):
143
+ return format_datetime(dt, format=fmt, locale=locale)
144
+ else:
145
+ return format_datetime(dt, format=fmt, locale=locale)
146
+
147
+
148
+ def fmt_duration(delta: Optional[timedelta]) -> str:
149
+ """Format a timedelta as a human-readable string.
150
+
151
+ This uses simple formatting (not locale-dependent) as duration
152
+ formatting varies significantly and Babel doesn't handle it well.
153
+
154
+ Args:
155
+ delta: Timedelta to format (None returns "-")
156
+
157
+ Returns:
158
+ Formatted duration string (e.g., "2h 30m", "45m 12s")
159
+ """
160
+ if delta is None:
161
+ return "-"
162
+
163
+ total_seconds = int(delta.total_seconds())
164
+ if total_seconds < 0:
165
+ return "-"
166
+
167
+ hours, remainder = divmod(total_seconds, 3600)
168
+ minutes, seconds = divmod(remainder, 60)
169
+
170
+ # Omit seconds when duration >= 10 minutes
171
+ if hours > 0 or minutes >= 10:
172
+ if hours > 0:
173
+ return f"{hours}h {minutes}m"
174
+ else:
175
+ return f"{minutes}m"
176
+ elif minutes > 0:
177
+ return f"{minutes}m {seconds}s"
178
+ else:
179
+ return f"{seconds}s"
cli/i18n_setup.py ADDED
@@ -0,0 +1,94 @@
1
+ """Internationalization setup for stundu CLI."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import i18n
8
+
9
+ from cli.config import get_config_value
10
+
11
+
12
+ def get_locales_path() -> Path:
13
+ """Get the path to the locales directory."""
14
+ return Path(__file__).parent / "locales"
15
+
16
+
17
+ def get_locale_from_env() -> Optional[str]:
18
+ """Get locale from environment variables."""
19
+ for var in ("LC_ALL", "LC_MESSAGES", "LANG"):
20
+ value = os.environ.get(var)
21
+ if value and value != "C" and value != "POSIX":
22
+ # Extract language code: "lv_LV.UTF-8" → "lv"
23
+ return value.split("_")[0].split(".")[0]
24
+ return None
25
+
26
+
27
+ def get_locale(cli_override: Optional[str] = None) -> str:
28
+ """Get locale with precedence: CLI > config > environment > fallback.
29
+
30
+ Args:
31
+ cli_override: Language code from CLI --lang option
32
+
33
+ Returns:
34
+ Two-letter language code (e.g., 'en', 'lv')
35
+ """
36
+ # 1. CLI override (highest priority)
37
+ if cli_override:
38
+ return cli_override
39
+
40
+ # 2. Config file
41
+ config_locale = get_config_value("locale")
42
+ if config_locale:
43
+ return config_locale
44
+
45
+ # 3. Environment variables
46
+ env_locale = get_locale_from_env()
47
+ if env_locale:
48
+ return env_locale
49
+
50
+ # 4. Fallback
51
+ return "en"
52
+
53
+
54
+ def setup_i18n(cli_override: Optional[str] = None) -> str:
55
+ """Initialize i18n with the appropriate locale.
56
+
57
+ Args:
58
+ cli_override: Language code from CLI --lang option
59
+
60
+ Returns:
61
+ The locale that was set
62
+ """
63
+ from cli.formats import set_format_locale
64
+
65
+ locales_path = get_locales_path()
66
+
67
+ i18n.set("load_path", [str(locales_path)])
68
+ i18n.set("file_format", "json")
69
+ i18n.set("filename_format", "{locale}.{format}")
70
+ i18n.set("fallback", "en")
71
+ i18n.set("enable_memoization", True)
72
+
73
+ locale = get_locale(cli_override)
74
+ i18n.set("locale", locale)
75
+
76
+ # Initialize date/time formatting locale
77
+ set_format_locale(locale)
78
+
79
+ return locale
80
+
81
+
82
+ def t(key: str, **kwargs) -> str:
83
+ """Translate a key with optional parameters.
84
+
85
+ This is a convenience wrapper around i18n.t().
86
+
87
+ Args:
88
+ key: Translation key (e.g., 'app.no_active_task')
89
+ **kwargs: Placeholder values
90
+
91
+ Returns:
92
+ Translated string
93
+ """
94
+ return i18n.t(key, **kwargs)
cli/locales/de.json ADDED
@@ -0,0 +1,174 @@
1
+ {
2
+ "de": {
3
+ "app": {
4
+ "no_active_task": "Keine aktive Aufgabe.",
5
+ "run_help": "Führen Sie 'stundu --help' aus, um verfügbare Befehle anzuzeigen.",
6
+ "currently_tracking": "Aktuelle Erfassung: %{tags}",
7
+ "started_at": "Gestartet um: %{time}",
8
+ "elapsed": "Vergangen: %{duration}",
9
+ "error": "Fehler: %{message}"
10
+ },
11
+ "duration": {
12
+ "hours_minutes": "%{hours}Std %{minutes}Min",
13
+ "minutes_seconds": "%{minutes}Min %{seconds}s",
14
+ "seconds": "%{seconds}s",
15
+ "running": "%{duration} (läuft)"
16
+ },
17
+ "start": {
18
+ "started_tracking": "Erfassung gestartet: %{tags}"
19
+ },
20
+ "stop": {
21
+ "stopped_tracking": "Erfassung gestoppt: %{tags}",
22
+ "duration": "Dauer: %{duration}"
23
+ },
24
+ "status": {
25
+ "no_active_entry": "Kein aktiver Eintrag. Verwenden Sie 'start' um die Erfassung zu beginnen."
26
+ },
27
+ "log": {
28
+ "no_entries": "Keine Einträge gefunden.",
29
+ "no_entries_today": "No entries today. Use --all to show all entries.",
30
+ "total": "Gesamt: %{duration} (%{count} Einträge)",
31
+ "headers": {
32
+ "num": "#",
33
+ "start": "Start",
34
+ "end": "Ende",
35
+ "duration": "Dauer",
36
+ "tags": "Tags",
37
+ "status": "Status"
38
+ },
39
+ "active": "AKTIV"
40
+ },
41
+ "cancel": {
42
+ "no_active_entry": "Kein aktiver Eintrag zum Abbrechen.",
43
+ "cancelled": "Abgebrochen: %{tags}",
44
+ "discarded": "Gestartet um: %{time} (verworfen)"
45
+ },
46
+ "continue": {
47
+ "already_tracking": "Fehler: Erfassung läuft bereits: %{tags}",
48
+ "stop_first": "Stoppen Sie zuerst den aktuellen Eintrag.",
49
+ "no_previous": "Keine vorherigen Einträge zum Fortsetzen.",
50
+ "continuing": "Fortsetzen: %{tags}"
51
+ },
52
+ "set_rate": {
53
+ "success": "Satz festgelegt für '%{tag}': %{amount} %{currency}/Stunde"
54
+ },
55
+ "set_workday": {
56
+ "invalid_format": "Fehler: Format muss 'HH:MM-HH:MM' sein (z.B. 9:00-18:00)",
57
+ "success_tag": "Arbeitstag festgelegt für '%{tag}': %{hours}",
58
+ "success_global": "Globaler Arbeitstag festgelegt: %{hours}"
59
+ },
60
+ "set_afterhours": {
61
+ "success": "Überstunden-Modifikator festgelegt für '%{tag}': %{multiplier}x Basissatz"
62
+ },
63
+ "set_holiday": {
64
+ "success": "Feiertags-/Wochenend-Modifikator festgelegt für '%{tag}': %{multiplier}x Basissatz"
65
+ },
66
+ "set_style": {
67
+ "success": "Tabellenstil festgelegt: %{style}",
68
+ "invalid": "Ungültiger Stil '%{style}'. Gültige Stile: %{valid}"
69
+ },
70
+ "report": {
71
+ "no_entries": "Keine Einträge gefunden.",
72
+ "no_entries_range": "Keine Einträge im angegebenen Datumsbereich gefunden.",
73
+ "no_entries_tag": "Keine Einträge mit Tag '%{tag}' gefunden.",
74
+ "no_completed": "Keine abgeschlossenen Einträge gefunden.",
75
+ "invalid_date": "Ungültiges Datumsformat: %{date}. Verwenden Sie JJJJ-MM-TT.",
76
+ "header": "Bericht",
77
+ "header_for_tag": "für '%{tag}'",
78
+ "header_range": "(%{start} bis %{end})",
79
+ "header_from": "(ab %{start})",
80
+ "header_until": "(bis %{end})",
81
+ "total_time": "Gesamtzeit: %{duration}",
82
+ "by_tag": "Nach Tag:",
83
+ "total": "Gesamt:",
84
+ "no_billable": "Kein abrechenbarer Betrag (keine Sätze für Tags konfiguriert)",
85
+ "headers": {
86
+ "date": "Datum",
87
+ "start": "Start",
88
+ "end": "Ende",
89
+ "duration": "Dauer",
90
+ "tags": "Tags",
91
+ "cost": "Kosten"
92
+ }
93
+ },
94
+ "delete": {
95
+ "no_entries": "Keine Einträge gefunden.",
96
+ "not_found": "Fehler: Eintrag @%{num} nicht gefunden. Gültiger Bereich: @1 bis @%{max}",
97
+ "invalid_number": "Fehler: Ungültige Eintragsnummer '%{ref}'",
98
+ "no_entries_tag": "Keine Einträge mit Tag '%{tag}' gefunden.",
99
+ "will_delete": "%{count} Einträge werden gelöscht:",
100
+ "and_more": " ... und %{count} weitere",
101
+ "confirm": "Diese Einträge löschen?",
102
+ "cancelled": "Abgebrochen.",
103
+ "deleted": "%{count} Einträge gelöscht."
104
+ },
105
+ "modify": {
106
+ "no_entries": "Keine Einträge gefunden.",
107
+ "invalid_ref": "Fehler: Referenz muss mit @ beginnen (z.B. @3)",
108
+ "not_found": "Fehler: Eintrag @%{num} nicht gefunden. Gültiger Bereich: @1 bis @%{max}",
109
+ "invalid_number": "Fehler: Ungültige Eintragsnummer '%{ref}'",
110
+ "parse_error_start": "Fehler beim Parsen der Startzeit: %{error}",
111
+ "parse_error_end": "Fehler beim Parsen der Endzeit: %{error}",
112
+ "no_modifications": "Keine Änderungen angegeben. Verwenden Sie --add-tag, --remove-tag, --start oder --end.",
113
+ "modified": "Eintrag @%{num} geändert:",
114
+ "tags": " Tags: %{tags}",
115
+ "start": " Start: %{time}",
116
+ "end": " Ende: %{time}",
117
+ "duration": " Dauer: %{duration}"
118
+ },
119
+ "rates": {
120
+ "no_billable": "Keine abrechenbaren Tags konfiguriert. Verwenden Sie 'set-rate' zum Hinzufügen.",
121
+ "headers": {
122
+ "tag": "Tag",
123
+ "rate": "Satz",
124
+ "workday": "Arbeitstag",
125
+ "afterhours": "Überstunden",
126
+ "holiday": "Feiertag"
127
+ }
128
+ },
129
+ "setup": {
130
+ "welcome": "Willkommen bei Stundu!",
131
+ "first_run": "Dies scheint Ihr erster Start zu sein.",
132
+ "prompt_create_config": "Möchten Sie eine Konfigurationsdatei erstellen?",
133
+ "data_file_prompt": "Geben Sie den Pfad zur Datendatei ein",
134
+ "data_file_default": "(Standard: %{default})",
135
+ "config_created": "Konfiguration gespeichert unter: %{path}",
136
+ "skipped": "Einrichtung übersprungen. Standardwerte werden verwendet.",
137
+ "already_configured": "Konfiguration existiert bereits: %{path}",
138
+ "reconfigure_prompt": "Möchten Sie neu konfigurieren?",
139
+ "run_setup": "Führen Sie 'stundu setup' aus, um zu konfigurieren."
140
+ },
141
+ "set_my": {
142
+ "success": "%{field} festgelegt: %{value}"
143
+ },
144
+ "set_client": {
145
+ "success": "%{field} für '%{tag}' festgelegt: %{value}"
146
+ },
147
+ "invoice": {
148
+ "no_entries": "Keine Einträge gefunden.",
149
+ "no_entries_tag": "Keine Einträge mit Tag '%{tag}' gefunden.",
150
+ "no_completed": "Keine abgeschlossenen Einträge gefunden.",
151
+ "no_billable": "Keine abrechenbaren Einträge gefunden. Setzen Sie Sätze mit 'configure rate'.",
152
+ "multiple_currencies": "Mehrere Währungen gefunden (%{currencies}). Erstellen Sie separate Rechnungen für jede Währung.",
153
+ "invalid_date": "Ungültiges Datumsformat: %{date}. Verwenden Sie JJJJ-MM-TT.",
154
+ "created_html": "Rechnung erstellt: %{path}",
155
+ "created_pdf": "Rechnung erstellt: %{path}"
156
+ },
157
+ "import": {
158
+ "available": "Verfügbare Importierer:",
159
+ "usage": "Verwendung: stundu import <importierer> [OPTIONEN]",
160
+ "importer_not_found": "Importierer '%{name}' nicht gefunden.",
161
+ "source_not_found": "Quelle nicht gefunden: %{path}",
162
+ "invalid_date": "Ungültiges Datumsformat: %{date}. Verwenden Sie JJJJ-MM-TT.",
163
+ "importing": "Importiere von %{source}...",
164
+ "errors": "Fehler aufgetreten:",
165
+ "summary": "%{count} Einträge gefunden (%{skipped} durch Filter übersprungen).",
166
+ "no_entries": "Keine Einträge zum Importieren.",
167
+ "preview": "Vorschau:",
168
+ "dry_run": "Testlauf - keine Änderungen gespeichert.",
169
+ "confirm": "Diese Einträge importieren?",
170
+ "cancelled": "Import abgebrochen.",
171
+ "success": "%{count} Einträge erfolgreich importiert."
172
+ }
173
+ }
174
+ }