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 +36 -0
- cli/config.py +182 -0
- cli/formats.py +179 -0
- cli/i18n_setup.py +94 -0
- cli/locales/de.json +174 -0
- cli/locales/en.json +174 -0
- cli/locales/es.json +174 -0
- cli/locales/et.json +174 -0
- cli/locales/fr.json +174 -0
- cli/locales/it.json +174 -0
- cli/locales/lt.json +174 -0
- cli/locales/lv.json +174 -0
- cli/locales/pl.json +174 -0
- cli/locales/pt.json +174 -0
- cli/locales/uk.json +174 -0
- core/__init__.py +32 -0
- core/billing.py +481 -0
- core/engine.py +85 -0
- core/entry_references.py +182 -0
- core/invoice.py +152 -0
- core/models.py +94 -0
- core/rates.py +304 -0
- core/time_segments.py +161 -0
- exporters/__init__.py +93 -0
- exporters/ledger.py +223 -0
- exporters/timewarrior.py +159 -0
- importers/__init__.py +116 -0
- importers/ledger.py +207 -0
- importers/timewarrior.py +192 -0
- storage/__init__.py +4 -0
- storage/base.py +26 -0
- storage/csv_storage.py +381 -0
- stundu-0.3.0.dist-info/METADATA +484 -0
- stundu-0.3.0.dist-info/RECORD +39 -0
- stundu-0.3.0.dist-info/WHEEL +5 -0
- stundu-0.3.0.dist-info/entry_points.txt +2 -0
- stundu-0.3.0.dist-info/licenses/LICENSE +21 -0
- stundu-0.3.0.dist-info/top_level.txt +6 -0
- stundu.py +1781 -0
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
|
+
}
|