Follows #141 (merged to next as PR 142), which put the save path under {downloadDirectory}/originals/{fileID}{ext} and left it undefined when no download directory was given.
Owner's words (chat, 2026-10-01 ~17:5x UTC):
default quak download path should be ./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.photoid.ext . files in the cache don’t count as downloaded but when downloaded to the save path are pulled from the cache not the network.
This also answers question 1 on 141 (comment 107489): a copy only in the cache does not count as downloaded.
Definition of done:
By default, a photo's save path is ./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<photoid>.<ext>. The date parts come from the photo's date, <photoid> is its id and <ext> is its original extension. savePath always has a value. A configured download directory replaces ./photos.
Whatever writes originals to the save path uses this layout, lib.backup() included. No second layout remains.
isLocal is true only when the original is at the save path. A copy only in quak's cache does not count.
Downloading to the save path copies the original from the cache when the cache holds it. It goes to the network only when the cache does not.
Before implementation, a comment on this issue states the reading of every choice the words leave open: which date and which time zone, what photoid is, the extension, live photos, what happens to the old originals/ and collections/ layout, and how relative ./photos resolves.
Tests cover the layout, the default, the cache-first download and isLocal. README and API docs are updated. The check gate is green on next.
One PR to next, independently reviewed and squash-merged.
model: opus-5-5
Follows https://git.eeqj.de/sneak/quak/issues/141 (merged to `next` as PR 142), which put the save path under `{downloadDirectory}/originals/{fileID}{ext}` and left it undefined when no download directory was given.
Owner's words (chat, 2026-10-01 ~17:5x UTC):
> default quak download path should be ./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.photoid.ext . files in the cache don’t count as downloaded but when downloaded to the save path are pulled from the cache not the network.
This also answers question 1 on 141 (comment 107489): a copy only in the cache does not count as downloaded.
Definition of done:
- By default, a photo's save path is `./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<photoid>.<ext>`. The date parts come from the photo's date, `<photoid>` is its id and `<ext>` is its original extension. `savePath` always has a value. A configured download directory replaces `./photos`.
- Whatever writes originals to the save path uses this layout, `lib.backup()` included. No second layout remains.
- `isLocal` is true only when the original is at the save path. A copy only in quak's cache does not count.
- Downloading to the save path copies the original from the cache when the cache holds it. It goes to the network only when the cache does not.
- Before implementation, a comment on this issue states the reading of every choice the words leave open: which date and which time zone, what `photoid` is, the extension, live photos, what happens to the old `originals/` and `collections/` layout, and how relative `./photos` resolves.
- Tests cover the layout, the default, the cache-first download and `isLocal`. README and API docs are updated. The check gate is green on `next`.
- One PR to `next`, independently reviewed and squash-merged.
model: opus-5-5
clawbot
self-assigned this 2026-10-01 19:52:06 +02:00
Path:{root}/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.{fileID}{ext}, for example photos/2026/2026-03/2026-03-01/2026-03-01.12345.jpg.
Date: the photo's takenAt. That is the date the user set in Ente if they edited it, otherwise the creation time. It is the same date photo.year and the timeline use.
Time zone: the time zone of the machine running quak, as photo.year and the timeline use. If that time zone changes, or the date is edited in Ente, the save path changes. The next run then writes the photo at the new path and leaves the old copy, because quak never deletes an original.
photoid: Ente's numeric fileID. It is unique on one server and does not change when a file is shared, moved, trashed or edited (#140 (comment)).
Extension: taken from the file's name as uploaded, not from a later rename. This is what quak uses today: case kept, and .bin when there is none or it is unsafe.
Live photos: the image and the video sit side by side, for example ….12345.heic and ….12345.mov, with the extensions found inside the live photo. A ….12345.livephoto.json beside them names the two. savePath is the image's path. Before the first download it carries the title's extension, as it does today.
Root:./photos, resolved against the working directory once, when the library opens. Library.open({ downloadDirectory }) replaces it, and quak backup passes the directory it is given. savePath is always a string, even in a library without a content cache.
Downloading: new method await photo.download(), which writes the original to savePath. If the cache holds it, it is copied from there; otherwise it is fetched straight to savePath. When the original is already there, nothing happens. lib.backup() uses the same paths and the same rule. original() and content() still fill only the cache, and they read from savePath when the original is there.
isLocal: true when the whole original is at savePath; for a live photo, both files. A copy in the cache does not count.
Old layout:originals/ is gone. quak is pre-1.0 and has no existing backups, so nothing reads, detects or moves an old tree. Each file's metadata JSON sits beside the photo as YYYY-MM-DD.{fileID}.json. collections/ stays as the album view, linking to the new paths. thumbnails/ and failures.json stay where they are.
Both questions were answered in #143 (comment). The folder date uses the machine's local time zone, and there is no migration.
Model: opus-5-5
Reading of each open choice.
- **Path:** `{root}/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.{fileID}{ext}`, for example `photos/2026/2026-03/2026-03-01/2026-03-01.12345.jpg`.
- **Date:** the photo's `takenAt`. That is the date the user set in Ente if they edited it, otherwise the creation time. It is the same date `photo.year` and the timeline use.
- **Time zone:** the time zone of the machine running quak, as `photo.year` and the timeline use. If that time zone changes, or the date is edited in Ente, the save path changes. The next run then writes the photo at the new path and leaves the old copy, because quak never deletes an original.
- **photoid:** Ente's numeric `fileID`. It is unique on one server and does not change when a file is shared, moved, trashed or edited (https://git.eeqj.de/sneak/quak/issues/140#issuecomment-107491).
- **Extension:** taken from the file's name as uploaded, not from a later rename. This is what quak uses today: case kept, and `.bin` when there is none or it is unsafe.
- **Live photos:** the image and the video sit side by side, for example `….12345.heic` and `….12345.mov`, with the extensions found inside the live photo. A `….12345.livephoto.json` beside them names the two. `savePath` is the image's path. Before the first download it carries the title's extension, as it does today.
- **Root:** `./photos`, resolved against the working directory once, when the library opens. `Library.open({ downloadDirectory })` replaces it, and `quak backup` passes the directory it is given. `savePath` is always a string, even in a library without a content cache.
- **Downloading:** new method `await photo.download()`, which writes the original to `savePath`. If the cache holds it, it is copied from there; otherwise it is fetched straight to `savePath`. When the original is already there, nothing happens. `lib.backup()` uses the same paths and the same rule. `original()` and `content()` still fill only the cache, and they read from `savePath` when the original is there.
- **`isLocal`:** true when the whole original is at `savePath`; for a live photo, both files. A copy in the cache does not count.
- **Old layout:** `originals/` is gone. quak is pre-1.0 and has no existing backups, so nothing reads, detects or moves an old tree. Each file's metadata JSON sits beside the photo as `YYYY-MM-DD.{fileID}.json`. `collections/` stays as the album view, linking to the new paths. `thumbnails/` and `failures.json` stay where they are.
Both questions were answered in https://git.eeqj.de/sneak/quak/issues/143#issuecomment-107662. The folder date uses the machine's local time zone, and there is no migration.
Model: opus-5-5
Implementer's brief. Implement the reading in #143 (comment) exactly.
Standing rule: quak is pre-1.0, with no installed base and no data anywhere. Never write a migration or upgrade path, never move or convert old data, and never keep backward compatibility. The old originals/ layout is simply gone: no code detects, reads or moves it, and no test or doc mentions it.
One place computes the save path. A small function takes the root and an EnteFile and returns {root}/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.{fileID}{ext}.
The date is the photo's takenAt in the machine's local time zone. Compute takenAt by the same rule toPhotoRecord uses: export a helper from src/library/records.ts and use it in both places, so the two can never disagree.
{ext} is safeExtension(file.metadata.title), which is what nameInOriginals uses today.
Photo.savePath, isLocal, the content cache's present check, photo.download() and lib.backup() all use this one function. Nothing else builds the path.
Generalise "stored".storedOriginal and the live-photo JSON helpers in src/library/content.ts currently assume the name {fileID}{ext}. Make them take the name without its extension, so they work in both places that use them:
the cache keeps {fileID};
the save path uses YYYY-MM-DD.{fileID}.
A live photo is stored when YYYY-MM-DD.{fileID}.livephoto.json names an image and a video that both have content, and the JSON check still accepts only names with that base.
Library.
downloadDirectory defaults to path.resolve("photos"), computed once in Library.open.
PhotoContent.savePath returns string.
Photo.savePath is always a string, including in a library with no content cache, so do not route it only through the cache.
isLocal is true when the original is stored at the save path.
The cache serves original() from the save path when the original is stored there.
photo.download() (async), resolving to the same { path, bytes, videoPath? } as original(), at the save path:
If the original is already stored there, return it.
Otherwise get it the way backup does today: backupOriginal(fileID, savePath) writes a fetch straight to the save path and returns the cached copy when the cache holds one.
Then place it the way placeOriginal does: copy atomically from the cache, put a live photo's two files and its JSON beside it, and create the folders.
Move placeOriginal and copyAtomic out of src/backup.ts to where both can use them. Backup's originals phase becomes a call to the same code for each file.
Backup (src/backup.ts):
Originals go to the save path under the backup's directory. The per-file metadata JSON is written beside the original as YYYY-MM-DD.{fileID}.json.
Keep collections/: each link points at the new path. Removing stale links recognises the links quak made into the date folders, and nothing the user made.
Leave thumbnails/{fileID}.jpg and failures.json as they are.
Docs.
README "Backup layout": rewrite it for the new tree.
README "Read surface" and "Opening a library": cover savePath, isLocal, download() and the ./photos default.
Update TODO.md wherever it describes the backup layout.
Tests.
The layout and date folders, using a fixed date at noon so the time zone cannot shift the day.
The ./photos default resolving against the working directory.
savePath without a content cache.
isLocal: false when the copy is only in the cache, true after download().
download() copying from the cache when it is cached, with the source never called.
download() fetching when nothing is cached.
download() doing nothing on a second call.
Live photos.
lib.backup() writing the new layout.
collections/ links pointing at the new paths.
Existing backup tests change only their expected paths to the new layout. Never drop or relax one of their checks.
Out of scope: a ULID, and a year or date filter.
Process
Branch from current next, with one PR whose base is next. Title: Save path ./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.fileID.ext; download() from the cache first (closes #143). Label it needs-review and assign clawbot.
Gate only with make check, and format only with make fmt. Both run in Docker. Run Docker builds under flock -w 1800 /srv/code/tmp/.docker-build.lock. A timeout there exits 1 without running anything; retry, and never read it as red. Remove every container you start.
Pull and rebase onto next again right before pushing, and re-run make check after any conflict resolution.
Edit files by hand. No sed -i, perl -pi, awk or scripted replacements.
Keep the change small and plain enough for a newcomer to follow in one reading. Coin no new terms.
Ask no interactive questions. A point only sneak can settle goes on the PR with the reading you took.
Commits and the PR body end with a Model: line naming your model id. Never name the company.
Stay under 2 GiB of RAM.
Model: opus-5-5
**Implementer's brief.** Implement the reading in https://git.eeqj.de/sneak/quak/issues/143#issuecomment-107571 exactly.
**Standing rule: quak is pre-1.0, with no installed base and no data anywhere.** Never write a migration or upgrade path, never move or convert old data, and never keep backward compatibility. The old `originals/` layout is simply gone: no code detects, reads or moves it, and no test or doc mentions it.
**One place computes the save path.** A small function takes the root and an `EnteFile` and returns `{root}/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.{fileID}{ext}`.
- The date is the photo's `takenAt` in the machine's local time zone. Compute `takenAt` by the same rule `toPhotoRecord` uses: export a helper from `src/library/records.ts` and use it in both places, so the two can never disagree.
- `{ext}` is `safeExtension(file.metadata.title)`, which is what `nameInOriginals` uses today.
- `Photo.savePath`, `isLocal`, the content cache's present check, `photo.download()` and `lib.backup()` all use this one function. Nothing else builds the path.
**Generalise "stored".** `storedOriginal` and the live-photo JSON helpers in `src/library/content.ts` currently assume the name `{fileID}{ext}`. Make them take the name without its extension, so they work in both places that use them:
- the cache keeps `{fileID}`;
- the save path uses `YYYY-MM-DD.{fileID}`.
A live photo is stored when `YYYY-MM-DD.{fileID}.livephoto.json` names an image and a video that both have content, and the JSON check still accepts only names with that base.
**Library.**
- `downloadDirectory` defaults to `path.resolve("photos")`, computed once in `Library.open`.
- `PhotoContent.savePath` returns `string`.
- `Photo.savePath` is always a string, including in a library with no content cache, so do not route it only through the cache.
- `isLocal` is true when the original is stored at the save path.
- The cache serves `original()` from the save path when the original is stored there.
**`photo.download()`** (async), resolving to the same `{ path, bytes, videoPath? }` as `original()`, at the save path:
- If the original is already stored there, return it.
- Otherwise get it the way backup does today: `backupOriginal(fileID, savePath)` writes a fetch straight to the save path and returns the cached copy when the cache holds one.
- Then place it the way `placeOriginal` does: copy atomically from the cache, put a live photo's two files and its JSON beside it, and create the folders.
- Move `placeOriginal` and `copyAtomic` out of `src/backup.ts` to where both can use them. Backup's originals phase becomes a call to the same code for each file.
**Backup** (`src/backup.ts`):
- Originals go to the save path under the backup's directory. The per-file metadata JSON is written beside the original as `YYYY-MM-DD.{fileID}.json`.
- Keep `collections/`: each link points at the new path. Removing stale links recognises the links quak made into the date folders, and nothing the user made.
- Leave `thumbnails/{fileID}.jpg` and `failures.json` as they are.
**Docs.**
- README "Backup layout": rewrite it for the new tree.
- README "Read surface" and "Opening a library": cover `savePath`, `isLocal`, `download()` and the `./photos` default.
- Update `TODO.md` wherever it describes the backup layout.
**Tests.**
- The layout and date folders, using a fixed date at noon so the time zone cannot shift the day.
- The `./photos` default resolving against the working directory.
- `savePath` without a content cache.
- `isLocal`: false when the copy is only in the cache, true after `download()`.
- `download()` copying from the cache when it is cached, with the source never called.
- `download()` fetching when nothing is cached.
- `download()` doing nothing on a second call.
- Live photos.
- `lib.backup()` writing the new layout.
- `collections/` links pointing at the new paths.
Existing backup tests change only their expected paths to the new layout. Never drop or relax one of their checks.
Out of scope: a ULID, and a year or date filter.
**Process**
- Branch from current `next`, with one PR whose base is `next`. Title: `Save path ./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.fileID.ext; download() from the cache first (closes #143)`. Label it `needs-review` and assign `clawbot`.
- Gate only with `make check`, and format only with `make fmt`. Both run in Docker. Run Docker builds under `flock -w 1800 /srv/code/tmp/.docker-build.lock`. A timeout there exits 1 without running anything; retry, and never read it as red. Remove every container you start.
- Pull and rebase onto `next` again right before pushing, and re-run `make check` after any conflict resolution.
- Edit files by hand. No `sed -i`, `perl -pi`, `awk` or scripted replacements.
- Keep the change small and plain enough for a newcomer to follow in one reading. Coin no new terms.
- Ask no interactive questions. A point only sneak can settle goes on the PR with the reading you took.
- Commits and the PR body end with a `Model:` line naming your model id. Never name the company.
- Stay under 2 GiB of RAM.
Model: opus-5-5
Owner ruling on both questions of 107571 (here as 107662, and in chat, 2026-10-01 ~18:06 UTC):
Question 1: the date folders use the local time zone of the machine running quak.
Question 2: quak is pre-1.0 alpha prototype software that nobody has ever run, so no existing backups exist. Nothing moves or migrates an old originals/ layout. The old layout is simply gone: no migration code, no detection of the old tree, and no tests or docs about it. This is the standing pre-1.0 rule: no installed base, so no migrations.
model: opus-5-5
Owner ruling on both questions of 107571 (here as 107662, and in chat, 2026-10-01 ~18:06 UTC):
- Question 1: the date folders use the local time zone of the machine running quak.
- Question 2: quak is pre-1.0 alpha prototype software that nobody has ever run, so no existing backups exist. Nothing moves or migrates an old `originals/` layout. The old layout is simply gone: no migration code, no detection of the old tree, and no tests or docs about it. This is the standing pre-1.0 rule: no installed base, so no migrations.
model: opus-5-5
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Follows #141 (merged to
nextas PR 142), which put the save path under{downloadDirectory}/originals/{fileID}{ext}and left it undefined when no download directory was given.Owner's words (chat, 2026-10-01 ~17:5x UTC):
This also answers question 1 on 141 (comment 107489): a copy only in the cache does not count as downloaded.
Definition of done:
./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<photoid>.<ext>. The date parts come from the photo's date,<photoid>is its id and<ext>is its original extension.savePathalways has a value. A configured download directory replaces./photos.lib.backup()included. No second layout remains.isLocalis true only when the original is at the save path. A copy only in quak's cache does not count.photoidis, the extension, live photos, what happens to the oldoriginals/andcollections/layout, and how relative./photosresolves.isLocal. README and API docs are updated. The check gate is green onnext.next, independently reviewed and squash-merged.model: opus-5-5
Reading of each open choice.
{root}/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.{fileID}{ext}, for examplephotos/2026/2026-03/2026-03-01/2026-03-01.12345.jpg.takenAt. That is the date the user set in Ente if they edited it, otherwise the creation time. It is the same datephoto.yearand the timeline use.photo.yearand the timeline use. If that time zone changes, or the date is edited in Ente, the save path changes. The next run then writes the photo at the new path and leaves the old copy, because quak never deletes an original.fileID. It is unique on one server and does not change when a file is shared, moved, trashed or edited (#140 (comment))..binwhen there is none or it is unsafe.….12345.heicand….12345.mov, with the extensions found inside the live photo. A….12345.livephoto.jsonbeside them names the two.savePathis the image's path. Before the first download it carries the title's extension, as it does today../photos, resolved against the working directory once, when the library opens.Library.open({ downloadDirectory })replaces it, andquak backuppasses the directory it is given.savePathis always a string, even in a library without a content cache.await photo.download(), which writes the original tosavePath. If the cache holds it, it is copied from there; otherwise it is fetched straight tosavePath. When the original is already there, nothing happens.lib.backup()uses the same paths and the same rule.original()andcontent()still fill only the cache, and they read fromsavePathwhen the original is there.isLocal: true when the whole original is atsavePath; for a live photo, both files. A copy in the cache does not count.originals/is gone. quak is pre-1.0 and has no existing backups, so nothing reads, detects or moves an old tree. Each file's metadata JSON sits beside the photo asYYYY-MM-DD.{fileID}.json.collections/stays as the album view, linking to the new paths.thumbnails/andfailures.jsonstay where they are.Both questions were answered in #143 (comment). The folder date uses the machine's local time zone, and there is no migration.
Model: opus-5-5
Implementer's brief. Implement the reading in #143 (comment) exactly.
Standing rule: quak is pre-1.0, with no installed base and no data anywhere. Never write a migration or upgrade path, never move or convert old data, and never keep backward compatibility. The old
originals/layout is simply gone: no code detects, reads or moves it, and no test or doc mentions it.One place computes the save path. A small function takes the root and an
EnteFileand returns{root}/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.{fileID}{ext}.takenAtin the machine's local time zone. ComputetakenAtby the same ruletoPhotoRecorduses: export a helper fromsrc/library/records.tsand use it in both places, so the two can never disagree.{ext}issafeExtension(file.metadata.title), which is whatnameInOriginalsuses today.Photo.savePath,isLocal, the content cache's present check,photo.download()andlib.backup()all use this one function. Nothing else builds the path.Generalise "stored".
storedOriginaland the live-photo JSON helpers insrc/library/content.tscurrently assume the name{fileID}{ext}. Make them take the name without its extension, so they work in both places that use them:{fileID};YYYY-MM-DD.{fileID}.A live photo is stored when
YYYY-MM-DD.{fileID}.livephoto.jsonnames an image and a video that both have content, and the JSON check still accepts only names with that base.Library.
downloadDirectorydefaults topath.resolve("photos"), computed once inLibrary.open.PhotoContent.savePathreturnsstring.Photo.savePathis always a string, including in a library with no content cache, so do not route it only through the cache.isLocalis true when the original is stored at the save path.original()from the save path when the original is stored there.photo.download()(async), resolving to the same{ path, bytes, videoPath? }asoriginal(), at the save path:backupOriginal(fileID, savePath)writes a fetch straight to the save path and returns the cached copy when the cache holds one.placeOriginaldoes: copy atomically from the cache, put a live photo's two files and its JSON beside it, and create the folders.placeOriginalandcopyAtomicout ofsrc/backup.tsto where both can use them. Backup's originals phase becomes a call to the same code for each file.Backup (
src/backup.ts):YYYY-MM-DD.{fileID}.json.collections/: each link points at the new path. Removing stale links recognises the links quak made into the date folders, and nothing the user made.thumbnails/{fileID}.jpgandfailures.jsonas they are.Docs.
savePath,isLocal,download()and the./photosdefault.TODO.mdwherever it describes the backup layout.Tests.
./photosdefault resolving against the working directory.savePathwithout a content cache.isLocal: false when the copy is only in the cache, true afterdownload().download()copying from the cache when it is cached, with the source never called.download()fetching when nothing is cached.download()doing nothing on a second call.lib.backup()writing the new layout.collections/links pointing at the new paths.Existing backup tests change only their expected paths to the new layout. Never drop or relax one of their checks.
Out of scope: a ULID, and a year or date filter.
Process
next, with one PR whose base isnext. Title:Save path ./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.fileID.ext; download() from the cache first (closes #143). Label itneeds-reviewand assignclawbot.make check, and format only withmake fmt. Both run in Docker. Run Docker builds underflock -w 1800 /srv/code/tmp/.docker-build.lock. A timeout there exits 1 without running anything; retry, and never read it as red. Remove every container you start.nextagain right before pushing, and re-runmake checkafter any conflict resolution.sed -i,perl -pi,awkor scripted replacements.Model:line naming your model id. Never name the company.Model: opus-5-5
clawbot referenced this issue2026-10-01 20:06:46 +02:00
local timezone yes. there are no existing backups, nobody has ever used this pre-1.0 prototype alpha software.
Owner ruling on both questions of 107571 (here as 107662, and in chat, 2026-10-01 ~18:06 UTC):
originals/layout. The old layout is simply gone: no migration code, no detection of the old tree, and no tests or docs about it. This is the standing pre-1.0 rule: no installed base, so no migrations.model: opus-5-5