Skip to content
Merged
82 changes: 77 additions & 5 deletions .claude/skills/transifex/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ Translations for this app live in the shared Nextcloud Transifex project
(`o:nextcloud:p:nextcloud`, resource `r:attendance` — see `.tx/config`).
Translators raise questions and issues on individual source strings there.
This skill covers the loop: fetch what's open, triage, fix source strings
where the translators have a point, get the user's approval, then post
replies.
where the translators have a point, persist answers as `TRANSLATORS`
context hints in the code, get the user's approval, then post replies.

## Prerequisites

Expand All @@ -24,14 +24,22 @@ replies.
## Step 1 — Fetch and reconstruct threads

```bash
python3 .claude/skills/transifex/scripts/tx_feedback.py list --open
python3 .claude/skills/transifex/scripts/tx_feedback.py list --open --new
```

This prints every thread that needs attention as JSON: the source string
(text, key, context), whether it has an open issue, and the full comment
transcript. `--me` defaults to `magdeflow` (the maintainer account); a
thread "needs attention" if it has an open issue or the last word wasn't
ours. Drop `--open` to see everything including settled threads.
ours.

`--new` additionally drops threads without activity since the
`answered_up_to` watermark in `state.json` (next to this SKILL.md) —
that is what keeps already-handled threads from being answered twice,
especially open issues we cannot resolve ourselves (they stay "open"
forever, see Step 5). Drop `--new` only when the user explicitly wants
to revisit older threads; drop `--open` to see everything including
settled threads.

Reading the output, keep in mind:

Expand All @@ -50,14 +58,57 @@ that context. Then sort:

1. **Just answer** — context questions ("noun or verb?", "what does {x}
mean?"). Draft a reply that explains where the string appears and how
it behaves; that is what the translator actually needs.
it behaves; that is what the translator actually needs. **Every
answered context question must also become a `TRANSLATORS` context
hint in the code** (see "Context hints" below) — the reply helps one
translator once, the hint helps every language forever.
2. **Fix the source string** — the translator is right (wrong spacing,
sentence fragments, split plurals, capitalization, concatenation).
Fix per the translation guidelines in `CLAUDE.md` (only first word
capitalized, NBSP before `…`, no fragments, `n()` for plurals, …).
3. **Needs the user's call** — product wording decisions, renames,
anything ambiguous. Ask instead of guessing.

## Context hints for translators (always, when answering)

Whenever a thread gets answered, persist the answer as a context hint
next to the string in the code, per
https://docs.nextcloud.com/server/latest/developer_manual/basics/translations.html#provide-context-hints-for-translators
— the extractor copies these comments into the .pot, so future
translators of every language see the hint directly in Transifex.

Syntax by location:

```php
// TRANSLATORS Shown as button label in the check-in popup
$l->t('Add new file');
```

```javascript
// TRANSLATORS Name that is appended to copied files
const label = t('attendance', 'copy')
```

```html
<!-- TRANSLATORS Marks a question as mandatory -->
<span>{{ t('attendance', 'Required') }}</span>
```

Rules:

- The comment binds only to the **first** translation call on the
following line — one call per line, each with its own comment.
- In Vue `<template>` blocks use the HTML comment form, in `<script>`
the `//` form.
- Word the hint as what a translator needs (where the string appears,
noun vs. verb, what placeholders contain) — essentially a condensed
version of the reply you are about to post.
- Mobile-only strings: the hint goes on the registration in the App.vue
mobile block (that is what the extractor reads), not (only) in the
Flutter code.
- Hints appear in Transifex only after the next .pot sync — mention
that in the reply.

## Step 3 — Apply source-string fixes (both repos!)

Source strings are shared with the Flutter app (`../attendance-flutter`),
Expand Down Expand Up @@ -119,3 +170,24 @@ Changing issue status needs project-maintainer rights on the shared
nextcloud project; a translator-level token gets `403`. Don't fight it:
the regular reporters (e.g. rakekniven) resolve their issues themselves
once answered. Mention the limitation to the user instead of retrying.

## Step 6 — Advance the watermark

Once every reply for this session is posted (and only then), record how
far we got:

```bash
python3 .claude/skills/transifex/scripts/tx_feedback.py mark-answered
```

This writes the newest comment date seen on the resource (including our
own just-posted replies — the API is the source of truth, not the local
clock) into `answered_up_to` in `state.json`. The next `list --new` run
then only surfaces threads with newer activity, so nothing gets answered
twice. Commit `state.json` together with the session's code changes so
the watermark survives across worktrees and machines.

Skip this step if replies are still pending (e.g. the user postponed a
draft) — an advanced watermark would hide those threads from the next
run. If that happens anyway, `mark-answered --date <ISO>` can move the
watermark back explicitly.
112 changes: 107 additions & 5 deletions .claude/skills/transifex/scripts/tx_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,23 @@
string, with source text and issue status.
list --open Only threads with an open issue or whose last
message is not from --me (default: magdeflow).
list --new Only threads with activity newer than the
answered_up_to watermark in state.json (kept
next to the skill). Combine with --open.
find TEXT Search the resource's source strings by key or
source text (case-insensitive substring) and
print their string ids.
reply STRING_ID MESSAGE Post a reply comment on a string's thread.
STRING_ID may be the full id or just the s:<hash>
suffix. The language relationship (required by
the API) is copied from the thread's existing
comments.
comments. With --issue, an open issue is created
instead of a plain comment. For strings without
an existing thread, pass --language (l:de_DE).
mark-answered Record the newest comment date seen on the
resource as the answered_up_to watermark in
state.json (or pass --date ISO explicitly).
Run after all replies for a session are posted.
resolve COMMENT_ID Mark an issue comment as resolved. NOTE: needs
project-maintainer rights; translator-level
tokens get 403 (see SKILL.md).
Expand All @@ -34,6 +46,22 @@
ORGANIZATION = 'o:nextcloud'
PROJECT = 'o:nextcloud:p:nextcloud'
RESOURCE = 'o:nextcloud:p:nextcloud:r:attendance'
STATE_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), '..', 'state.json')


def read_state():
try:
with open(STATE_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}


def write_state(state):
with open(STATE_FILE, 'w') as f:
json.dump(state, f, indent=1, ensure_ascii=False)
f.write('\n')


def token():
Expand All @@ -47,7 +75,9 @@ def token():
def request(method, url, payload=None):
# curl instead of urllib: it picks up the egress proxy and its CA
# bundle from the environment without extra configuration.
cmd = ['curl', '-sS', '-X', method,
# -g: pagination links from the API contain literal [] which curl would
# otherwise treat as glob ranges.
cmd = ['curl', '-sSg', '-X', method,
'-H', f'Authorization: Bearer {token()}',
'-w', '\n%{http_code}']
if payload is not None:
Expand Down Expand Up @@ -118,6 +148,11 @@ def full_string_id(string_id):

def cmd_list(args):
threads = fetch_threads()
watermark = read_state().get('answered_up_to', '') if args.new else ''
if args.new and not watermark:
print('state.json has no answered_up_to watermark yet — listing '
'everything. Run mark-answered after replying.',
file=sys.stderr)
out = []
for sid, comments in threads.items():
has_open_issue = any(
Expand All @@ -126,6 +161,10 @@ def cmd_list(args):
comments and comments[-1]['author'] != f'u:{args.me}')
if args.open and not needs_attention:
continue
# Watermark dates come from the same API field as the comment
# dates, so plain string comparison of the ISO timestamps works.
if watermark and comments[-1]['date'] <= watermark:
continue
out.append({
'string_id': sid,
'string': fetch_string(sid),
Expand All @@ -138,11 +177,29 @@ def cmd_list(args):
print()


def cmd_find(args):
params = urllib.parse.urlencode({'filter[resource]': RESOURCE})
needle = args.text.lower()
out = []
for s in paginate(f'{API}/resource_strings?{params}'):
attributes = s['attributes']
haystack = (attributes.get('key') or '') + ' ' + json.dumps(
attributes.get('strings') or {}, ensure_ascii=False)
if needle in haystack.lower():
out.append({
'string_id': s['id'],
'key': attributes.get('key'),
'source': attributes.get('strings'),
})
json.dump(out, sys.stdout, indent=1, ensure_ascii=False)
print()


def cmd_reply(args):
sid = full_string_id(args.string_id)
threads = fetch_threads()
thread = threads.get(sid)
if not thread:
thread = threads.get(sid, [])
if not thread and not args.language:
sys.exit(f'No existing thread found for {sid} — replies attach to a '
'language, which is taken from the thread. For a brand-new '
'comment, pass --language (e.g. l:de_DE).')
Expand All @@ -156,11 +213,15 @@ def cmd_reply(args):
for c in reversed(thread):
if c['language'] and c['language'] not in candidates:
candidates.append(c['language'])
# New issues are created open; the API rejects an explicit 'status'
# attribute on creation.
attributes = {'message': args.message,
'type': 'issue' if args.issue else 'comment'}
status, data = None, None
for language in candidates:
payload = {'data': {
'type': 'resource_string_comments',
'attributes': {'message': args.message, 'type': 'comment'},
'attributes': attributes,
'relationships': {
'resource_string': {
'data': {'type': 'resource_strings', 'id': sid}},
Expand All @@ -177,6 +238,30 @@ def cmd_reply(args):
sys.exit(0 if status < 300 else 1)


def cmd_mark_answered(args):
if args.date:
newest = args.date
else:
# Take the newest comment date from the API itself instead of the
# local clock — clocks may drift, the API is the source of truth.
newest = max(
(c['date'] for comments in fetch_threads().values()
for c in comments), default='')
if not newest:
sys.exit('No comments found on the resource; pass --date '
'to set the watermark explicitly.')
state = read_state()
previous = state.get('answered_up_to', '')
if previous and newest < previous and not args.date:
sys.exit(f'Refusing to move the watermark backwards '
f'({previous} -> {newest}); pass an explicit --date '
f'if that is really intended.')
state['answered_up_to'] = newest
write_state(state)
print(json.dumps({'ok': True, 'answered_up_to': newest,
'previous': previous or None}))


def cmd_resolve(args):
payload = {'data': {
'id': args.comment_id,
Expand All @@ -202,14 +287,31 @@ def main():
p_list = sub.add_parser('list', help='list comment threads')
p_list.add_argument('--open', action='store_true',
help='only threads needing attention')
p_list.add_argument('--new', action='store_true',
help='only threads with activity newer than the '
'answered_up_to watermark in state.json')
p_list.add_argument('--me', default='magdeflow',
help='username treated as "us" for needs_attention')
p_list.set_defaults(func=cmd_list)

p_mark = sub.add_parser(
'mark-answered',
help='record the newest comment date as the answered watermark')
p_mark.add_argument('--date',
help='explicit ISO timestamp instead of the newest '
'comment date from the API')
p_mark.set_defaults(func=cmd_mark_answered)

p_find = sub.add_parser('find', help='search source strings by text/key')
p_find.add_argument('text')
p_find.set_defaults(func=cmd_find)

p_reply = sub.add_parser('reply', help='post a reply on a thread')
p_reply.add_argument('string_id')
p_reply.add_argument('message')
p_reply.add_argument('--language', help='override, e.g. l:de_DE')
p_reply.add_argument('--issue', action='store_true',
help='open an issue instead of a plain comment')
p_reply.set_defaults(func=cmd_reply)

p_resolve = sub.add_parser('resolve', help='resolve an issue comment')
Expand Down
3 changes: 3 additions & 0 deletions .claude/skills/transifex/state.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"answered_up_to": "2026-07-24T17:53:15Z"
}
12 changes: 8 additions & 4 deletions lib/Notification/Notifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,17 +154,21 @@ public function prepare(INotification $notification, string $languageCode): INot
);
if ($notification->getSubject() === 'booking_confirmed') {
$notification->setParsedSubject(
$l->t('You are scheduled: %1$s on %2$s', [$appointmentName, $appointmentDate])
// TRANSLATORS Push notification subject: the person got a place in the appointment ("scheduled in", German "eingeplant" — not "geplant": the appointment itself is not being planned). %1$s is the appointment name, %2$s the date.
$l->t('You are scheduled for %1$s on %2$s', [$appointmentName, $appointmentDate])
);
$notification->setParsedMessage(
$l->t('You have been scheduled for this appointment.')
// TRANSLATORS Push notification body, personal and friendly — confirms the person got a place in the appointment (German "eingeplant", not "geplant") and thanks them for responding.
$l->t('You are scheduled for this appointment. Thank you for your response!')
);
} else {
$notification->setParsedSubject(
$l->t('Not scheduled: %1$s on %2$s', [$appointmentName, $appointmentDate])
// TRANSLATORS Push notification subject: the person did not get a place in the appointment this time (German "nicht eingeplant", not "nicht geplant"). %1$s is the appointment name, %2$s the date.
$l->t('You are not scheduled for %1$s on %2$s', [$appointmentName, $appointmentDate])
);
$notification->setParsedMessage(
$l->t('You are not scheduled for this appointment this time.')
// TRANSLATORS Push notification body, personal and friendly — gently tells the person they are not part of this appointment this time and thanks them for responding.
$l->t('Unfortunately, you are not part of this appointment this time. Thank you for your response!')
);
}
$notification->setIcon($this->urlGenerator->getAbsoluteURL(
Expand Down
10 changes: 6 additions & 4 deletions lib/Service/IcalService.php
Original file line number Diff line number Diff line change
Expand Up @@ -233,13 +233,15 @@ private function generateVEvent(
if ($responseState === 'yes' && $this->configService->isBookingEnabled()) {
if ($bookingStatus === 'booked') {
$transp = 'OPAQUE';
$summary = $appointment->getName()
. ' (' . $l->t('Me') . ': ' . $l->t('Scheduled') . ')';
// TRANSLATORS Status marker appended to the calendar event title — the person got a place in the appointment (German "Eingeplant", not "Geplant"; the appointment itself is not being planned).
$statusLabel = $l->t('Scheduled');
} else {
$transp = 'TRANSPARENT';
$summary = $appointment->getName()
. ' (' . $l->t('Me') . ': ' . $l->t('Not scheduled') . ')';
// TRANSLATORS Status marker appended to the calendar event title — the person did not get a place in the appointment (German "Nicht eingeplant").
$statusLabel = $l->t('Not scheduled');
}
$summary = $appointment->getName()
. ' (' . $l->t('Me') . ': ' . $statusLabel . ')';
}

// Cancelled appointments: the event will not take place. This is the one
Expand Down
3 changes: 3 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ t('attendance', 'Not now')
t('attendance', 'Server Update Available')
t('attendance', "Your server's Attendance app may be outdated. Please update to version 1.34.0 or later for the best experience.")

// TRANSLATORS: "Attendance" is the app name here, not the act of attending — error screen title in the mobile app when the Attendance app cannot be reached on the server.
t('attendance', 'Attendance is not available')
t('attendance', 'The Attendance app could not be reached on your Nextcloud server. This usually has one of two reasons:')
t('attendance', 'The app is not installed on the server.')
Expand All @@ -356,7 +357,9 @@ t('attendance', 'days remaining')
t('attendance', 'One license for your whole Nextcloud server. No per-user fees.')
t('attendance', 'More than 100 active app users?')
t('attendance', 'Restore Purchases')
// TRANSLATORS: Standalone button label (nominative) opening the Terms of Service document — not part of a sentence; the consent sentence uses inline links instead.
t('attendance', 'Terms of Service')
// TRANSLATORS: Standalone button label (nominative) opening the privacy policy document — not part of a sentence; the consent sentence uses inline links instead.
t('attendance', 'Privacy Policy')
// TRANSLATORS: Keep the link syntax intact: translate only the label inside the square brackets, e.g. [your translation](terms) and [your translation](privacy). The identifiers (terms) and (privacy) are internal link targets and must stay unchanged.
t('attendance', 'By connecting, you agree to our [Terms of Service](terms) and acknowledge our [Privacy Policy](privacy).')
Expand Down
Loading
Loading