31 Commits

Author SHA1 Message Date
ignacionelson 386cb32ebb Translate the four strings that accumulated since 2.4.0
Sixteen locales, four strings each, which is the whole of what had drifted:
three from the IAM-role work (the toggle, its explainer, and the warning
that saving clears a stored key and secret) and one from this week's upload
limits ("Too many uploads are already in progress").

Terminology was taken from each catalogue rather than chosen: every locale
already had "Access key" and "Secret key", and the new strings reuse those
words exactly, so the explainer reads in the same vocabulary as the two
fields directly beneath it. Product nouns stay as they are — AWS, IAM, ECS,
EC2, EKS/IRSA, MinIO, Backblaze, Wasabi, ProjectSend.

Formality was read off each file instead of assumed: informal in ca, es, it,
nl and zh_CN, formal in cs, de, fr, id, pl, pt_BR, ru and tr, matching what
the neighbouring sentences already do. Spanish has three voseo entries among
thirty-three tuteo ones — they arrived with the password-reset work on
2026-09-08 (df44c46a, 6339ae15) and are the outliers, so these follow the
tuteo the rest of the file uses. Worth settling one way or the other by
somebody who speaks it, which is not a job for this commit.

The edits are additive: no entry reordered or rewritten, so the diff is the
four new lines per file and the comma the previous last line grew.

Verified past the point where a JSON file merely parses. The scanner reports
0 missing across all sixteen, the Locale suite passes, and the Spanish
strings were read off a rendered page in a browser — signed in, interface
switched to Spanish through the app's own control, with the explainer
wrapping to four lines inside its column and reading in the same words as
the "Clave de acceso" and "Clave secreta" labels under it. That screen only
draws its S3 half when the provider is S3 and the dev instance is on GCS, so
the provider was flipped for the reading and put back; it was snapshotted
first and restored to what it was, not to a default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:26:08 -03:00
ignacionelson 6339ae1514 Answer a failed reset the same way whatever failed
The screen leaked account existence a second way, through the write, and
this one is older than last night's: it is the scaffolding. Laravel
answers a failed reset with passwords.user for an address it cannot find
and passwords.token for a real one whose token is dead, and the controller
surfaced __($status) straight through. Two sentences, one difference, and
the difference is whether the account is here.

passwords.throttled is the third and the sharpest. The broker throttles
per user, so an address nobody holds can never be throttled — being told
to wait is being told the account exists.

All of them collapse to one sentence now. Nothing is lost: the action is
the same in every case, and /forgot-password one step earlier already
refuses to say whether an address has an account. Keeping three messages
was only ever more precise about a thing we had decided not to say.

Found because the portal session went looking for the GET oracle I had
just fixed, found theirs, and also found a POST variant I had not thought
to check. I had it too.

The test asserts the two refusals are identical rather than naming the
sentence, so it survives the wording changing.
2026-09-09 08:04:54 -03:00
ignacionelson df44c46a12 Say a reset link has expired before asking for the work
The page rendered the form without looking at the token, so somebody
opening a link an hour late typed a password, typed it again to confirm,
and was then told "this password reset token is invalid" — a word nobody
outside the code knows, at the end rather than the start. Links last an
hour and people open them late. That is ordinary, not an error to be
scolded for.

store() still validates and is still the rule; there is a test that a
spent token is refused there whatever the page drew. This is only the
screen being honest a minute earlier.

An address that is missing, or belongs to nobody, is drawn as the form was
before. Partly because an unanswerable question is not an expired link,
but mostly because a page that said "expired" for a real address and
something else for an unknown one would answer whether an account exists
here to anybody typing guesses — the exact property /forgot-password
protects by saying "a link will be sent if the account exists". Two tests
pin that.

Worth having now rather than later: the advisories publishing with this
release will send more people than usual through this screen, in a hurry
and some of them frightened.

Found by the portal session's user, who opened a real link an hour and
forty minutes after it was sent.
2026-09-08 20:08:28 -03:00
ignacionelson a1f59e133b Translate the strings the security fixes added
Three, sixteen locales: the Entra optional-claim instruction on the
social-login screen, and the two the profile screen gained when changing
an address started asking for a password.

The catalogues gate the release build, so these had to land before the
version could be stamped.
2026-09-08 19:59:31 -03:00
ignacionelson 2be423d685 Translate the download counts and the copy-link control
Four strings, sixteen locales. Everything else was already complete, so
this is the whole of the debt from the client-portal work.

Czech, Polish and Russian do not get "downloaded :count times": those
languages inflect the noun according to the number in front of it, so no
single string can be right for every value. They read as a labelled count
instead — "Pobrania: :count — ostatnie :date" — which is the shape the
other numeric strings in those catalogues already use.

German and Turkish stay formal, Spanish, Dutch, Polish and Chinese stay
informal, as the rest of each catalogue does.
2026-09-08 18:22:25 -03:00
ignacionelson d7d7acce85 Put the announcement behind the header icon too, from one source
A message worth showing was only on the dashboard, which means somebody
who works in Files and Clients all day never meets it. It now also sits
behind an icon next to the notification bell, and that is on every page.

**One shared prop, not two.** "The same message in both places" is the
requirement, and two props would have drifted the first time anybody
edited one — so the hook moved out of DashboardController into
HandleInertiaRequests, and the dashboard reads the same shared value the
header does. The band and the dropdown also share the component that
renders the words, for the same reason: the reliable way to keep two
renderings identical is not to have two.

Renamed with it. ResolvingDashboardCallout was accurate for about an hour
and became a lie the moment it appeared somewhere else; it is
ResolvingAnnouncement now, and the prop is `announcement`. Free to rename
because nothing has shipped yet — the only other reference was
cloud-modules', by string, updated alongside.

The icon follows UpdateAvailableIcon beside it: absent entirely when there
is nothing to say rather than a dead control, and a plain dot instead of a
count, because there is only ever one of these and a "1" would invite
somebody to look for the second.

Two tests worth naming. One asserts the message reaches a page that is not
the dashboard, which is the whole point of the addition. The other asserts
a client is shown nothing even from a listener that sets it
unconditionally — a client's header carries the bell too, and staff
messages must not reach it however careless the listener.
2026-09-08 02:17:56 -03:00
ignacionelson da1f432d87 Let an installation stop calling home, two different ways
Every instance reached projectsend.org twice a day and an operator could
stop neither. The news feed had no switch of any kind — FetchNewsCommand
went straight to the request, touching Settings only to write results back.
The update check had one, but its default is on, and a managed fleet had
been setting PROJECTSEND_CHECK_FOR_UPDATES=false for months against code
that reads no such variable: check_for_updates is a database setting, so
the environment never touched it and updates were enabled fleet-wide the
whole time.

They look like one problem and are two, which is why they are fixed
differently.

**The news feed gets a Setting**, its own key, default on. A Cloud client
with view_news sees that card today — DashboardController gates it on the
permission alone, with a comment saying in as many words that it is both
editions and carries no capability. So switching it off is an operator's
choice rather than an edition's, and it must stay reachable everywhere.
Its own key rather than riding on check_for_updates because they are two
different wants: "do not tell me about releases" and "do not show me the
project's news" are asked separately, and an installation with no outbound
access at all wants both.

**The update check gets a capability guard**, ahead of the setting it
already had, and deliberately not a Setting of its own. On a managed
installation the result is unreachable rather than unwanted: the
dashboard's System card and the update UI are both gated on
Capability::SystemUpdates, which is Community-only, and the image is
chosen by whoever provisioned the instance. A Setting would encode a fact
about the edition as a preference — leaving it switchable back on per
tenant, buying a nightly call for a number no screen can draw, and putting
the reason in a provisioning script rather than beside the code. A
self-hosted install holds the capability and loses nothing: its own
setting still decides.

Both guards return success rather than failure. A scheduled task that was
asked not to run has not failed, and reporting it as one would put a red
line in the scheduler history every night for an installation behaving
exactly as configured.

The news switch is on the General settings screen, outside the
can_manage_updates block that hides the update toggle where the capability
is absent — a setting only reachable by editing a database row is a row,
not a switch. Seven tests, and the two that matter go red when either
guard is removed. Sixteen locales translated in the same commit rather
than left for the pass, since a release is close.
2026-09-08 01:27:08 -03:00
ignacionelson 50a6a19455 Translate the client file editor into all sixteen locales
The eight strings the portal file editor added, which had been sitting in
English since the feature landed — the deliberate trade, but the pass is
due now that the English has settled.

Nothing else came up. The scan reports eight missing per locale and they
are all from this feature, so no unrelated drift crept in alongside it.

Written against each catalogue's own established voice rather than
translated fresh: Spanish stays informal, and "Expires on" takes the verb
its neighbouring "Leave empty for a file that never expires" already uses
in each language. Quoting follows each locale too — Russian keeps its
guillemets, Japanese its corner brackets, Chinese and Vietnamese their
curly quotes — matching how the sibling folder-deletion warning already
reads there.

Every :name placeholder survives verbatim, checked rather than assumed,
and the diff is additive: the one deleted line per file is the previous
last entry re-emitted with a comma.

Checked on the screen, not only in the file, which is the part a parsing
JSON cannot show: the editor rendered in Spanish with every label and hint
in place, "Vence el" agreeing with the hint below it, and no console
errors. The dev instance's Client role was snapshotted, granted the keys
for the run, and restored; the throwaway file it needed is gone.

Locale suite green, 13 tests. The 249 orphans each catalogue reports are
older than this work and left alone deliberately — scan.php cannot see a
key held as data or supplied by a package, and a wrongly deleted entry
reverts a screen to English in silence.
2026-09-07 12:48:10 -03:00
ignacionelson 616aa49867 Translate the download delivery screens into all sixteen locales
The 29 English strings added by the file-delivery work: the System
widget row and its explanation dialog, and the settings panel that
repeats it.

Product nouns left alone throughout -- PHP, nginx, Apache, LiteSpeed,
X-Sendfile, mod_xsendfile, XSendFilePath, PROJECTSEND_FILE_DELIVERY,
INSTALL.md, S3, Google Cloud. "PHP" as a whole string stays "PHP",
which is why every locale gains one entry counted as untranslated, the
same way API and OK are.

Purely additive: appended rather than merged in sorted position, so each
diff is 29 new lines plus a comma on the line that used to be last.

Verified: scan reports 0 missing in all sixteen, 13 locale tests pass,
and the Spanish settings screen was read out of the running app rather
than out of the file.
2026-09-01 01:18:08 -03:00
ignacionelson fb931819e2 Translate the auth and settings screens into all sixteen locales
#1762 put six screens through the translator and deliberately left the
new keys for a focused pass; this is that pass, plus the one older gap
the scan turned up -- the directory-account notice on the password form.

Twenty-seven keys each, written to match what the catalogue beside them
already says: de and tr formal, es, nl, pl and zh_CN informal, and each
locale's own established vocabulary rather than a fresh choice per file
(nl keeps wachtwoord, tr keeps parola, ru keeps "адрес эл. почты").

"Or, return to" and "log in" are rendered as one sentence with a space
between them, so each pair was chosen to read as a phrase in that
language rather than translated word by word -- Turkish reorders it to
"Ya da giriş sayfasına dön", Japanese to "または ログインに戻る".

Additive only: 432 insertions, nothing reordered or reformatted. The
scan now reports zero missing across all sixteen.
2026-08-29 16:27:50 -03:00
ignacionelson 927c8fc991 Translate the bulk edit's second skip reason
#1747 split the bulk edit's skip message in two, because "you don't have permission to edit them" was being said to somebody about files they own. The new sentence arrived English only, so every non-English installation would have read the correct reason in the wrong language.

Translated from its near-twin rather than from scratch: the two messages differ in one clause, so each locale keeps the first sentence it already had, its own register -- de and tr formal, es, nl, pl and zh_CN informal -- and only the reason changes. That way the pair reads as one voice on the same screen, which is where a staff member meets both.

Added immediately after the sibling key in each file, which is also its sorted position, so the diff is one line per locale and nothing else moved.
2026-08-28 17:59:29 -03:00
ignacionelson 479dc61d2d Move branding into core, and leave white-labelling behind
Logo and watermark belonged in the private package for one reason: that
is where they were written. Nothing about them needs a hosted platform,
and an installation wanting its own mark on the pages it serves is the
ordinary case rather than the exotic one. They are core's now, and every
installation has them.

Hiding "Powered by ProjectSend" did not come. That is what a hosted
customer pays for, and its gate is not a capability key but the absence
of the code: cloud-modules keeps the listener, so an installation without
that package holds the column and has nothing able to read it. Flipping
an edition variable buys nothing, which was true before and stays true.
Core renders the switch where Capability::AttributionHide is held and has
no route that can save it -- there is a test asserting exactly that, which
fails the day white-labelling quietly becomes free.

The migrations move with their original filenames on purpose. A Cloud
tenant already ran them under those names, so Laravel skips them there
and the table and its data are untouched; a fresh install or a community
one runs them from here for the first time.

What got better on the way rather than merely moving:

The watermark listeners take core's real RenderingImage and
ResolvingImageRendering instead of duck-typed `object` payloads, and the
tests construct the genuine events rather than anonymous stand-ins that
imitated their shape. The package had to do it that way -- it builds with
no host present -- so three PHPStan ignore entries existed to describe
what the type system could not see. They are gone.

ModuleBoundaryTest asserted "branding is cloud-only, and the suite runs as
community", which was never what it was testing. It now reads the
capability off the route and subtracts it, so the invariant holds for
whichever module is installed.

The 43 branding strings arrived in all sixteen locales from the package's
own catalogues rather than being retranslated, and the package's are
pruned to the one string it still uses.

A hosted plan without branding subtracts branding.customize and
attribution.hide from the instance's environment. The row is never
deleted by that: a downgrade is usually an expired card rather than a
decision, and wiping somebody's artwork over a billing event is a loss
they would find weeks later with no way to know what it used to be.
Hiding reverses; deleting does not.
2026-08-28 13:27:10 -03:00
ignacionelson d83d2d9acb Translate the three strings the last release cycle added
Two seat counters and one folder-delete refusal, in all sixteen locales.
Additive only: nothing already in a catalogue was reordered or reworded,
so the diff is three lines per file.

Polish, Czech and Russian get three plural forms where the English has
two. Those languages inflect a noun by the number in front of it -- one
case for 2-4, another for 5 and up -- and the framework's selector picks
between three segments for them, so writing only the English pair would
have produced "5 pliki" where it has to be "5 plikow". Verified through
trans_choice at 1, 3 and 7.
2026-08-28 01:45:07 -03:00
ignacionelson d58e48301f Move the seat number to the end of the sentence
It read "limited to 1 staff accounts" -- the number sat directly in front
of a countable noun, which is the message a free-tier customer meets the
first time they try to add anybody.

Adding plural forms would fix English and not much else. Polish, Czech and
Russian inflect the noun by the number in front of it, on a three-way split
that a two-form string cannot express, so ':count kont' cannot be right for
every value however many variants it carries. Ending the sentence on the
number means no language has to agree with it -- the same shape the other
counted strings here already use.

Both strings rewritten in all sixteen locales rather than left to the next
translation pass, since the old key would otherwise go missing and block a
build. Checked at 1 and at 25 in English, Spanish, German, Polish and
Russian.
2026-08-27 17:35:10 -03:00
ignacionelson d2901e8304 Translate the seat-cap refusals, and say in the changelog what 2.2.0 now carries 2026-08-27 02:44:19 -03:00
ignacionelson 5d99ab94fd Say on screen when nothing is building zip downloads
Zip building moved onto its own queue, which a manual install's worker
has to be told about. update.sh repairs the service file and Docker is
unaffected, so the population left is somebody upgrading by hand who
skipped the release note — and for them the failure is the worst shape
available. Email keeps going out perfectly. Zip downloads never finish.
Nothing in any log says why, because nothing went wrong: the jobs sit on
a queue nobody is reading. The person who missed it has no reason to
suspect anything, so the notice has to go looking for them.

The application cannot see its own worker processes, only whether work
gets done, so the question is asked from the other end: was a build
requested that no worker ever picked up? That needs a record of when a
build *started*, which is what the new zip_downloads.started_at column
is — stamped before any of the work, so it says a worker had the row,
not that the row succeeded.

Two conditions, because either alone cries wolf. A build has waited past
five minutes and was never started, *and* no other build is in hand. The
second matters because one worker builds one archive at a time: a queue
behind a large build is a healthy queue, and its waiting rows look
exactly like abandoned ones until you notice something running. "In
hand" is bounded by the job's own timeout, so a worker that died holding
a build stops counting as alive an hour later.

The banner sits beside the stale-code one, on every staff page rather
than the dashboard alone, gated on view_system_info for the reason that
one already argues: a background worker not picking work up is a fact
about the machine, not a feature of an edition. It names the fix rather
than the symptom — "your worker command needs --queue=default,zips" —
because somebody reading that downloads are not being processed still
has to work out what to do about it.

Eight tests, covering both halves of the discrimination rather than just
the happy one: a queue waiting behind a live build stays quiet, and a
build held by a worker that died does not.

Translated into all sixteen locales in the same commit, since a release
is close and a banner nobody can read is worse than none.

Checked on screen as well as in assertions, with a real stalled row on
the dev stack: the banner renders, wraps, and reads correctly.
2026-08-27 00:12:42 -03:00
ignacionelson 3b51c5308c Translate the fourteen strings today's work added, into all sixteen locales
Everything merged today landed in English, which is the deliberate trade:
a feature never waits on a language nobody in the room speaks. This is
the pass that settles up.

Fourteen keys, sixteen locales, 224 entries. Appended rather than sorted
in, matching how the previous passes left these files, so the diff is
additions and one trailing comma per catalogue and nothing else.

One of the fourteen was a bug rather than a gap. The scoped
expired-files note was written with a `’` escape in the TSX, so the
scanner read the raw source and the runtime read the interpreted string:
two different keys for one sentence, and a catalogue entry for either one
would never have matched the other. The apostrophe is now a literal
character, which is what every other string in these files does.

Checked mechanically — every :placeholder survives, no plural pipe count
moved, `projectsend:erase-account` is intact in the two messages that
name it — and then read on screen, because a file that parses is not
evidence that a sentence fits its button. Settings -> Descargas renders
its label, its paragraph and its help text in Spanish with no overflow.

Orphans left alone at 250. The ten that touch today's subjects were
checked one by one and every one is a false positive of the kind the
skill warns about: `Expired files` is WIDGET_LABELS data, `Uploader` is a
role name from the database, `Page Expired` is laravel-lang's.
2026-08-26 22:55:18 -03:00
denkfabrik-li eade83a576 Translate the finalising-refusal message into all sixteen languages 2026-08-25 22:41:43 +02:00
ignacionelson 91d34b204c Let something other than a browser session identify itself to the audit log
An actor with no personal access token has always meant a browser, and
for as long as a session and a Sanctum token were the only two ways to
authenticate, that was true. It stops being true the moment anything else
can, and the failure is silent: the action gets recorded as a person
clicking, in the one table whose whole purpose is answering "did I do
that, or did something acting for me?"

Nothing misreports today — every call site that passes an explicit actor
is a browser request, an API request whose actor carries the token, or a
console command with no actor at all. This closes the trap before the AI
connector in cloud-modules walks into it.

ActivityOrigin is a closed enum, so core has to publish both the case and
the hook before a package can use either. ResolvingActivityOrigin is
asked only in the ambiguous case: a request carrying a token is the API
and a request with nobody signed in is public or system, and neither is
in any doubt, so neither is offered — one package must not be able to
quietly relabel how every integration's actions are attributed.

The person stays the actor. They authorised it, and a log naming the
assistant instead would lose the only fact that matters. What the
connector was called goes in api_token_name, beside a null token id,
because that column means a row in personal_access_tokens and this is not
one.

The new origin is kept out of the activity filter unless the edition can
actually produce it. A filter option that can only ever return nothing is
a feature dangled at an edition that does not have it, which is the one
thing the edition boundary exists not to do.
2026-08-25 14:19:32 -03:00
ignacionelson 7059ee293a Add a Zapier guide to the API documentation page
The API has had everything Zapier needs since it shipped — a bearer
token, an auth-test endpoint at /me, and list endpoints that return
newest-first with a stable id, which is exactly the shape a polling
trigger wants. What was missing was anyone saying so.

This is written for somebody wiring up a Zap, not for somebody writing
code, which is why it is a second document rather than a section of the
guide. Same reason it renders in-app rather than linking to GitHub: the
installations most likely to need it are the ones least likely to have
outbound internet access.

It says out loud the two things that will otherwise be discovered the
hard way — a token expires within a year and nothing renews it, and a
deletion cannot start a Zap because polling cannot see one.
2026-08-25 01:08:09 -03:00
ignacionelson 5e3ea5a48b Translate the OAuth mail strings into all sixteen languages
The 21 strings the Microsoft 365 and Gmail transports added in #1679.
Appended, so the diff is only what is new.

The two provider names stay in English on purpose — they are product
nouns, and will read as untranslated in the scanner the same way API and
OK do. The tenant-ID placeholder sits in a fixed-width input, so each
translation of it is kept to roughly the length of the English rather
than the length the sentence wants to be; a fuller Spanish rendering was
already truncating on screen.
2026-08-25 00:32:42 -03:00
ignacionelson b22d3cf33c Translate the storage provider strings into all sixteen locales
Eight new strings from the Google Cloud Storage work, and nothing else:
the scan reported the same eight missing everywhere, so this is a
translation pass rather than a backlog.

Each locale keeps the word for a bucket it was already using — kova in
Turkish, бакет in Russian, 存储桶 in Chinese — and its own level of
formality, Sie in German and vous in French against tú in Spanish and
Italian. "Google Cloud Storage" is a product name and stays as it is in
all sixteen, the way API and OK already do. The :field placeholder
survives verbatim, which is asserted rather than assumed.

Entries are inserted in place rather than appended, so each file shows
eight added lines and nothing else moved. Verified by re-running the
scan to zero missing, the Locale suite, and reading the settings screen
in Spanish in a browser — a file that parses is not evidence that a
sentence fits its button.
2026-08-24 19:40:58 -03:00
ignacionelson 503676647f Let a split-user host serve downloads
A download is not served by PHP. PHP authorizes it and hands the web
server the path with X-Accel-Redirect, so the web server has to open a
file PHP wrote. Where those are different users — cPanel and Plesk
commonly arrange it that way — it cannot: uploads land 0600 inside a 0700
directory, and traversing 0700 means being its owner. Nothing else on the
site shows a symptom. Uploading works, the library lists everything, and
only downloads fail, as ERR_INVALID_RESPONSE in the browser and
`open() ... failed (13: Permission denied)` in the web server's log.

FILES_WEB_SERVER_READABLE writes uploads 0644/0755 instead. Opt-in and
spread into the disk configuration rather than switched by a ternary, so
an install that does not set it keeps byte-for-byte the configuration it
had: the relaxed modes are readable by every account on the machine,
which is the wrong trade wherever the web server and PHP are one user, as
in the image and on most self-administered servers.

The two halves are not enforced alike, which is the part worth knowing.
`visibility` has Flysystem chmod each file after writing it, so 0644
holds under any umask. A directory is created by mkdir(), which masks its
mode argument, so 0755 is a ceiling: a pool at umask 0077 still produces
0700 and still cannot be traversed. That cannot be fixed from config, so
INSTALL.md carries it — how to tell the two users apart, the one-time
chmod for files already on disk, and the pool setting for the umask.
FilePermissionsTest asserts all three modes, umask cases included, since
the asymmetry is invisible from the configuration.

Reported by @denkfabrik-li (#1668), who diagnosed it and verified the
remedy on the affected host.
2026-08-21 16:34:14 -03:00
ignacionelson dd779fafbe Let the scheduled task names be translated
Ten task names sat in a private const as bare strings — 'Purge erased
accounts' and its nine siblings — so the Scheduler screen listed ten
English rows in the middle of an otherwise fully translated page. Found by
looking at the Spanish screen while checking the translation pass, not by
any check, because nothing could have reported it: the scan only sees
literals inside __(), and prose held as data under a key is invisible to
it. Nine of the ten had never been translatable in any language. The tenth,
'Check for updates', looked translated purely by coincidence — a button
elsewhere uses the same words, so the catalogue happened to have it.

A const cannot call __(), so the map becomes a method. That is the whole
change in substance.

The keys are untouched and stay untouched: they are the command names, they
are what the run history, the detail map, the frontend and the test
asserting this list matches the schedule all match on, and they are what
somebody types to run the thing by hand. Only the values were ever
language. The screen still prints the command name verbatim under each
translated label, which is the half a reader would copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:49:27 -03:00
ignacionelson 18917adaf2 Translate the pre-2.1.0 strings into all sixteen locales
The update-check work, the update-options dialog and the housekeeping
retention settings landed in English, as feature work here always does.
Twenty-eight strings, missing from every one of the sixteen catalogues,
which is a release gate rather than a nicety: build-release.sh refuses to
build while a locale is short.

They are the strings somebody reads while deciding whether to touch a
running installation — what --backup does, what happens if the dump fails,
that 0 means keep everything and that unread notifications are never
deleted whatever the setting says. Being English in a Japanese install is
worse for those than for most.

Placeholders survive verbatim, checked mechanically across all sixteen
rather than by eye: :version, :current, :date and :tag, and the literal
`sudo ./update.sh --help` and `UPDATE.md` inside the sentence that names
them.

"days" is the standalone suffix beside a number input, which is the Slavic
numeral trap — Polish, Czech and Russian inflect the noun according to the
number in front of it, and the number here is whatever the operator typed.
No single form is right for every value, so each takes the form that reads
correctly for most: dni, dní, дней.

Two collisions worth naming. "Up to date" and "Updated" are different
things — a status and a row label — and several locales would render both
the same word; they are deliberately distinct (aggiornato/ultimo
aggiornamento, up-to-date/bijgewerkt). Everything else follows the wording
already in each catalogue rather than inventing a second vocabulary for
the same ideas.

Additive only: 464 lines added, 16 removed, and every one of those 16 is a
trailing comma. Verified by scan (missing: 0 everywhere), by the Locale
suite, and by looking at the screen — the Spanish labels are the longest
of the sixteen and still fit their controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:53:47 -03:00
ignacionelson de615cbd79 Clear out the 29 catalogue entries that stopped applying
Nothing was missing this pass — the new strings were translated as they
landed — so this is step 3, the orphan review that has been deferred for
weeks with the count sitting at 277.

Of those 277, **188 are laravel-lang's own JSON keys** (HTTP status names,
"Whoops!", "Verify Email Address", the pagination labels). Upstream and
human-maintained; the skill says to leave them alone and a lang:update
would put them straight back. Identified by diffing against
vendor/laravel-lang/*/locales/es/*.json rather than by eye.

Another **60 are strings the scanner cannot see**: prose held as data and
rendered through t(VAR) — the dashboard widget labels, the portal's sort
options, EmailTemplateSlot's descriptions, CommentingRules' refusals,
convert.tsx's plural pairs — plus three that belong to the private
packages, which the scanner does not read at all.

That leaves **29 that are genuinely ours and genuinely gone**, and this
removes them. Each was checked as a quoted literal across app/, routes/,
database/, resources/ and all three package repos, then re-checked against
every data-driven translation source. The three that looked alive
("Assigned", "Result", "System settings") turned out to be substrings of
longer live strings — "Assigned the file :subject to :target", the
LdapProbeResult class name, "System settings were updated".

Two were reworded rather than removed, which is the case that matters:
the comment-visibility description and the public-listing paragraph both
say something slightly different in the code today, so their old
translations had silently stopped applying in all sixteen languages while
the new ones are already in place.

Orphans: 277 -> 248, and every one of the 248 is now accounted for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:31:00 -03:00
ignacionelson 29f1eaaa1b Do not thank a hosted customer for installing anything
Nobody on a managed installation installed ProjectSend, and the version
they are running is not theirs to keep or to change — it is looked after
for them. "Thank you for installing ProjectSend. You are running version
2.0.0, and it is yours to keep" thanked them for somebody else's work, on
the one screen whose entire job is to sound like a person wrote it.

Cloud now reads "Thank you for choosing ProjectSend" over "Your site is
ready, and keeping it running is our job." The version comes out of the
sentence there: it is true, and it is none of their concern, which is
exactly what makes it noise.

Community is unchanged, and so is the wording somebody sees revisiting
the page later — that one is neutral already.

Branched on the shared `edition` prop rather than a capability, because
the question being asked really is which edition this is: SystemUpdates
happens to line up today, but it is about who may update, not about who
installed.

Both new strings are in all sixteen catalogues in the same commit, since
this is a two-string change and splitting it would leave the hosted
greeting English-only for however long the next pass takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:43:23 -03:00
ignacionelson 090e975bb3 Translate the two greeting pages into all sixteen locales
Twenty-four strings each: the thank-yous, the eight quick-start steps and
their one-line descriptions, the Essential label, and the line from the
website at the foot of both pages. 336 entries in, 48 out.

The three removed are the what's-new hero sentences reworded earlier
today — a translation that silently stopped applying the moment the
English changed, which is exactly the orphan the skill says to clear. All
three were checked against resources/ and app/ first; none survives as a
literal anywhere.

Register follows each catalogue rather than the language's default, and
terminology follows the screen each step links to: the scheduler step
says "programador de tareas" in Spanish because that is what the sidebar
calls it, "Planer" in German, "Harmonogram" in Polish.

"Made with care in Argentina" is translated rather than left in English —
it is a sentence about the people who made this, and the people reading
it in Japanese deserve to read it too.

Checked in the browser with the interface in Spanish: "Gracias por
instalar ProjectSend", "Estás usando la versión 2.0.0, y es tuya",
IMPRESCINDIBLE on the mail server and the scheduler, and "Hecho con
cariño en Argentina. Compartido con el mundo." under the fold line. Scan
reports 0 missing in all sixteen; the Locale tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 20:29:09 -03:00
ignacionelson 55b02d510d Translate the post-update welcome into all sixteen locales
The eleven strings the welcome page introduced, now in every catalogue:
176 entries across sixteen files, appended rather than merged in, so the
diff is only what was added and no existing line moves.

Two of them are deliberately identical to the English. "Discord" is a
product name, and "ProjectSend :version" is a name and a placeholder with
nothing between them to translate — the scan counts both as untranslated
and that is the right answer.

Register follows each catalogue rather than the language's default: Sie
in German, vous in French, je in Dutch, Ty in Polish, tú in Spanish. The
thank-you sentence was written to sound like somebody meant it, since a
literal translation of a warm line is a worse translation than a free one.

Checked in the browser with the interface in Spanish, not only scanned:
"ProjectSend ahora está en la versión 2.0.0", "Ven a saludarnos", "Únete
a Discord", "Novedades" — every line fitting its space. Scan reports 0
missing in all sixteen; the Locale tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:44:18 -03:00
Ignacio Nelson dad8d21dc8 Translate the update strings into all sixteen locales (#1632)
The English for the updater, the stale-code banner and the activity-log
entry had settled, so this is their pass: 208 lines across sixteen
catalogues, every :placeholder intact, no existing entry reordered.

The activity row and its filter label were modelled on each catalogue's
own "Installed ProjectSend" / "ProjectSend was installed" pair, so the
new rows read like the ones beside them rather than like a different
translator wrote them.

One code change belongs to this pass rather than to the feature: the
update entry recorded "an unrecorded version" when the previous version
was unknown, and context values are substituted into the template
verbatim — never translated — so that sentence fragment would have sat
inside an otherwise Japanese or Polish row forever. It is a dash now.

Verified on a real install with the interface switched to Spanish:
"Sistema — Actualizó ProjectSend a 2.0.3, desde 2.0.2", and the action
filter reading "ProjectSend fue actualizado a una versión nueva".
Scan reports 0 missing in every locale; the Locale tests pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:13:17 -03:00
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00