mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.46.6: the detach promise, actually kept this time
v1.46.4 and v1.46.5 documented that detaching a plan leaves the image on disk,
added guards for it, and shipped tests. The guards were never reached: they sit
behind 'not superseded', and a file that left the configuration was called
superseded. From old_refs - new_refs alone, replacing a plan, detaching one and
deleting its space are indistinguishable — so all three deleted the file, at the
moment of the save, before any scheduled pass ever ran.
Every test I wrote for this called collect_plans(d, cfg, cfg): old config equal
to new, i.e. only the scheduled pass. The transition that mattered was never
exercised. Codex reproduced it in four lines.
Classification is by owner now:
space in both, plan A -> plan B : the user picked another image -> removed
space in both, plan -> none : detached -> kept
space gone : kept (the image was imported; a thirty-day
grace measured from file age is meaningless
anyway, it was uploaded months ago)
space has a plan, other file : rejected upload -> 1 h
Attachments follow the same shape: dropped from a device that still exists ->
removed (a trash button promises nothing); device gone -> kept; staging folder
-> 1 h.
Tests: a matrix per rule in the pure module, and — the part that was missing —
test_detaching_a_plan_keeps_the_file, which goes through real config/set calls:
attach, detach, assert the file is there, restart, assert again, re-attach,
replace, assert the replaced one is gone, delete the space, assert the plan
survives. Also strengthened the sweep/save race test to assert the save actually
succeeded and the config points at the specific expected file, per the report.
This commit is contained in:
@@ -121,14 +121,21 @@ def collect_attachments(
|
||||
) -> int:
|
||||
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
|
||||
|
||||
A file the old revision referenced and the new one does not was superseded
|
||||
by this commit and goes. Otherwise a staging folder (`up_*` — only ever a
|
||||
dialog that was never saved) is collected after PLAN_ORPHAN_TTL_S, and
|
||||
anything else waits out SCHEDULED_GRACE_S. Never raises: it runs behind a
|
||||
durable write.
|
||||
A file the old revision referenced and the new one does not, whose marker
|
||||
still exists, was removed on purpose — the dialog has a trash button and
|
||||
promises nothing. It goes. If the marker itself is gone, that is a different
|
||||
transition and the files are kept, like a deleted space's plan. A staging
|
||||
folder (`up_*` — only ever a dialog that was never saved) is collected after
|
||||
PLAN_ORPHAN_TTL_S, anything else after SCHEDULED_GRACE_S. Never raises: it
|
||||
runs behind a durable write.
|
||||
"""
|
||||
new_refs = attachment_refs(new_cfg)
|
||||
old_refs = attachment_refs(old_cfg)
|
||||
# Removing an attachment from a device that still exists is the user saying
|
||||
# "drop this one" — a trash button, no promise that anything is kept. A
|
||||
# device that is GONE is a different transition, and its files follow the
|
||||
# same rule as a deleted space's plan: kept.
|
||||
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
|
||||
# Same distinction as for plans. A staging folder (`up_*`) is different: it
|
||||
# only ever holds an upload from a dialog that was never saved, so the short
|
||||
# rule is exactly right there even on the timer.
|
||||
@@ -144,10 +151,9 @@ def collect_attachments(
|
||||
removed += sweep_upload_temps(files_dir, now)
|
||||
for folder in folders:
|
||||
# A staging folder only ever holds an upload from a dialog that was never
|
||||
# saved — unambiguous, so an hour is right. A marker's own folder is not:
|
||||
# removing an attachment is deliberate, but so is re-adding one, and the
|
||||
# file may have been detached rather than abandoned. Give it a month.
|
||||
limit = staging_cutoff if folder.name.startswith("up_") else cutoff
|
||||
# saved — unambiguous, so an hour is right, and no device owns it.
|
||||
staging = folder.name.startswith("up_")
|
||||
limit = staging_cutoff if staging else cutoff
|
||||
try:
|
||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||
except OSError:
|
||||
@@ -156,7 +162,15 @@ def collect_attachments(
|
||||
rel = f"{folder.name}/{item.name}"
|
||||
if rel in new_refs:
|
||||
continue
|
||||
if rel not in old_refs: # not superseded: absence alone is weak evidence
|
||||
dropped = rel in old_refs and folder.name in live_markers
|
||||
if not dropped:
|
||||
if not staging and folder.name not in live_markers:
|
||||
# No device owns this folder any more. Whether the file was
|
||||
# attached once or is a leftover upload we cannot tell, and
|
||||
# by the standing rule that means we keep it. (A staging
|
||||
# folder is exempt above: it has no device by construction.)
|
||||
continue
|
||||
# the device is there and never listed this file: a rejected upload
|
||||
try:
|
||||
if item.stat().st_mtime >= limit:
|
||||
continue
|
||||
@@ -196,6 +210,14 @@ def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
return out
|
||||
|
||||
|
||||
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""space id -> the plan file it references ('' when it has none)."""
|
||||
return {
|
||||
str(sp.get("id")): plan_basename(sp.get("plan_url"))
|
||||
for sp in (cfg or {}).get("spaces") or []
|
||||
}
|
||||
|
||||
|
||||
def is_plan_file(name: str) -> bool:
|
||||
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
||||
parts = name.split(".")
|
||||
@@ -242,10 +264,19 @@ def collect_plans(
|
||||
# nothing) destroyed two detached plans on 2026-07-28.
|
||||
# The short rule fits exactly one case: a space that HAS a plan, where any
|
||||
# other file of its own can only be a superseded or rejected upload.
|
||||
spaces = {str(sp.get("id")): sp.get("plan_url") for sp in (new_cfg or {}).get("spaces") or []}
|
||||
old_by_space = plan_by_space(old_cfg)
|
||||
new_by_space = plan_by_space(new_cfg)
|
||||
# A file that left the configuration tells us nothing on its own: replacing a
|
||||
# plan, detaching one and deleting a space all look identical from
|
||||
# `old_refs - new_refs`. Only the first is a deletion the user asked for
|
||||
# (HP-1465-01 — the guards below were written and then never reached,
|
||||
# because the code decided "superseded" before asking why).
|
||||
replaced = {
|
||||
name for space, name in old_by_space.items()
|
||||
if new_by_space.get(space) and new_by_space[space] != name
|
||||
}
|
||||
now_s = time.time() if now is None else now
|
||||
reject_cutoff = now_s - PLAN_ORPHAN_TTL_S
|
||||
cutoff = now_s - SCHEDULED_GRACE_S
|
||||
removed = 0
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
@@ -258,21 +289,24 @@ def collect_plans(
|
||||
for item in items:
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
superseded = item.name in old_refs
|
||||
if not superseded:
|
||||
if item.name not in replaced:
|
||||
# Not a replacement. PRODUCT RULE (owner's decision, 2026-07-28):
|
||||
# a file we were not told to delete is kept, however long it sits
|
||||
# there. Detaching a plan is one click to undo and the editor says
|
||||
# the image stays; deleting a space is deliberate but the image is
|
||||
# usually something the user imported and may not have elsewhere.
|
||||
# The two errors are not symmetrical — a few unnecessary megabytes
|
||||
# can always be removed by hand, a file we should not have removed
|
||||
# cannot be brought back.
|
||||
#
|
||||
# The single exception: a space that HAS a plan, and another file of
|
||||
# its own that has never been the plan. That can only be an upload
|
||||
# whose save was rejected, and an hour is plenty for it.
|
||||
space = item.name.split(".")[0]
|
||||
if space in spaces and not spaces[space]:
|
||||
# PRODUCT RULE (owner's decision, 2026-07-28): a detached plan
|
||||
# is never deleted, at any age. The space is there and currently
|
||||
# has no plan — the image was detached, one click undoes that,
|
||||
# and the editor says the file stays. The two errors are not
|
||||
# symmetrical: a few megabytes we did not need can always be
|
||||
# removed by hand, a file we should not have removed cannot be
|
||||
# brought back. When in doubt, keep it.
|
||||
if not new_by_space.get(space) or item.name in old_by_space.values():
|
||||
continue
|
||||
limit = reject_cutoff if space in spaces else cutoff
|
||||
try:
|
||||
if item.stat().st_mtime >= limit:
|
||||
if item.stat().st_mtime >= reject_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user