Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ Versioning follows [SemVer](https://semver.org/): **MAJOR.MINOR.PATCH**

---

## [1.38.0] — 2026-07-10

### Added
- **Ao cadastrar um filamento, pergunta se quer criar um rolo.** Depois de salvar
um filamento novo, o detalhe abre um modal oferecendo cadastrar um rolo daquele
filamento na hora (leva ao "Novo Spool" com o filamento já selecionado).
- **Coluna "Cor" na lista de rolos.** Cada linha mostra uma amostra da cor + o
nome (informado no filamento ou, na falta, o balde derivado do hexadecimal).

### Changed
- **Data de compra já vem preenchida com a data de hoje** no cadastro de rolo
(editável). Vale tanto no cadastro manual quanto vindo do fluxo acima. Na edição
de um rolo existente nada muda.
- **"Ver novidades" agora mostra o histórico acumulado.** Quem está várias versões
atrás vê todas as novidades entre a versão instalada e a última (não só a última
release). Fonte: o `CHANGELOG.md` na tag mais recente (via `raw.githubusercontent`,
sem o limite de 60/h da API); se a busca falhar, cai nas notas da última release.

## [1.37.0] — 2026-07-10

### Added
Expand All @@ -27,6 +45,8 @@ Versioning follows [SemVer](https://semver.org/): **MAJOR.MINOR.PATCH**
fila). Ao cadastrar vários, volta à lista e oferece adicionar **os N** à fila de
impressão de uma vez (uma etiqueta/QR por rolo), reusando o `label_queue_add_all`.

## [1.36.2] — 2026-06-11

### Fixed
- **Card "Ver novidades" some quando a API do GitHub falha (fica só o link).** A
página de atualização busca as notas da release pela REST API do GitHub
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.37.0
1.38.0
59 changes: 59 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,12 +316,16 @@ def bootstrap():
# página /admin/update (latest_release_notes), de forma tolerante a falha.
GITHUB_LATEST_RELEASE = RELEASES_URL + "/latest"
GITHUB_RELEASES_API = "https://api.github.com/repos/iscarelli/spool-control/releases/latest"
# CHANGELOG cru na tag mais recente — fonte das notas ACUMULADAS (várias versões
# atrás). É o CDN raw (sem o limite de 60/h da REST API) e traz o histórico por versão.
GITHUB_CHANGELOG_RAW = "https://raw.githubusercontent.com/iscarelli/spool-control/{tag}/CHANGELOG.md"

# Cache em memória (por worker). Sucesso vale 6h; falha re-tenta em 15min; um
# debounce de 30s evita martelar em refreshes seguidos. Fail-open: erro nunca
# quebra a página, só não mostra atualização.
_release_cache = {"tag": None, "ts": 0.0, "ok": False}
_notes_cache = {"tag": None, "notes": ""}
_cumulative_cache = {"key": None, "notes": ""}
_RELEASE_TTL_OK = 6 * 3600
_RELEASE_TTL_FAIL = 15 * 60
_RELEASE_DEBOUNCE = 30
Expand Down Expand Up @@ -435,6 +439,61 @@ def latest_release_notes():
return _notes_cache["notes"]


def _changelog_md(tag):
"""Baixa o CHANGELOG.md cru na tag `tag` (ex.: 'v1.38.0'). Levanta em falha —
chame dentro de try/except. Isolado numa função p/ os testes monkeypatcharem."""
url = GITHUB_CHANGELOG_RAW.format(tag=tag)
if not _is_public_host(urlsplit(url).hostname):
raise ValueError("host do raw.githubusercontent não-público")
req = urllib.request.Request(url, headers={"User-Agent": "spool-control"})
with _safe_opener.open(req, timeout=8) as resp:
return resp.read().decode()


def _slice_changelog(md, current, latest):
"""Extrai do CHANGELOG as seções de versão em (current, latest] — o que há de
novo entre a versão instalada (exclusive) e a última (inclusive). Devolve o
markdown concatenado (mais nova primeiro, como no arquivo) ou '' se nada casar."""
cur_t, lat_t = _version_tuple(current), _version_tuple(latest)
header_re = re.compile(r"^##\s+\[(\d+\.\d+\.\d+)\]")
sections, ver, buf = [], None, []
for ln in (md or "").replace("\r\n", "\n").split("\n"):
m = header_re.match(ln)
if m:
if ver is not None:
sections.append((ver, buf))
ver, buf = m.group(1), [ln]
elif ver is not None:
buf.append(ln)
if ver is not None:
sections.append((ver, buf))
kept = [buf for v, buf in sections if cur_t < _version_tuple(v) <= lat_t]
return "\n".join(ln for buf in kept for ln in buf).strip()


def cumulative_release_notes():
"""Notas ACUMULADAS para o card "Ver novidades": todas as seções do CHANGELOG
entre a versão instalada (exclusive) e a última publicada (inclusive) — assim
quem está várias versões atrás vê o histórico todo, não só a última. Fonte: o
CHANGELOG.md cru na tag latest. Fail-open: sem tag, ou se a busca/parse falhar
ou nada casar, cai nas notas da última release (comportamento anterior)."""
tag = _release_cache.get("tag")
current = current_version()
if not tag:
return latest_release_notes()
key = (current, tag)
if _cumulative_cache["key"] == key and _cumulative_cache["notes"]:
return _cumulative_cache["notes"]
try:
notes = _slice_changelog(_changelog_md(f"v{tag}"), current, tag)
if notes:
_cumulative_cache.update(key=key, notes=notes)
return notes
except Exception:
log.info("github_changelog.unavailable", exc_info=True)
return latest_release_notes()


# Subconjunto de Markdown usado nas release notes → HTML seguro, sem dependência
# externa. Escapa TUDO primeiro (anti-XSS) e só então insere as tags de formatação,
# então um `body` malicioso vindo do GitHub não consegue injetar HTML.
Expand Down
4 changes: 2 additions & 2 deletions routes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
app, admin_required, demo_blocked, t, MIN_PASSWORD_LEN, DEMO_MODE,
BRANDS_DIR, _fetch_brand_logo, _clean_domain, public_base_url,
RELEASES_URL, check_latest_release, current_version, _version_tuple,
latest_release_notes, render_release_notes,
cumulative_release_notes, render_release_notes,
)

log = log_cfg.get_logger()
Expand Down Expand Up @@ -228,7 +228,7 @@ def admin_update():
latest=latest,
update_available=bool(latest) and _version_tuple(latest) > _version_tuple(cur),
releases_url=RELEASES_URL,
release_notes=render_release_notes(latest_release_notes()) if latest else "",
release_notes=render_release_notes(cumulative_release_notes()) if latest else "",
)


Expand Down
6 changes: 4 additions & 2 deletions routes/filaments.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ def filaments_new():
notes=request.form.get("notes", "").strip(),
)
flash(t("Filamento cadastrado com sucesso"), "success")
return redirect(url_for("filaments_detail", filament_id=fid))
# Oferece cadastrar um rolo deste filamento já no detalhe (modal).
return redirect(url_for("filaments_detail", filament_id=fid, spool_prompt="1"))
except Exception:
log.error("filament.create_failed", exc_info=True)
flash(t("Erro ao processar. Tente novamente."), "danger")
Expand All @@ -73,8 +74,9 @@ def filaments_detail(filament_id):
abort(404)
spools = db.list_spools_for_filament(filament_id)
prev_id, next_id = db.filament_neighbors(filament_id)
spool_prompt = request.args.get("spool_prompt") == "1"
return render_template("filaments/detail.html", filament=filament, spools=spools,
prev_id=prev_id, next_id=next_id)
prev_id=prev_id, next_id=next_id, spool_prompt=spool_prompt)


@app.route("/filaments/<int:filament_id>/edit", methods=["GET", "POST"])
Expand Down
19 changes: 16 additions & 3 deletions routes/spools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
registro Niimbot e pesagem rápida."""
import io
import re
from datetime import date
from flask import (
render_template, request, redirect, url_for, session, flash, Response, abort, jsonify,
)
Expand All @@ -25,6 +26,17 @@ def _filaments_with_color_display():
return result


def _spools_with_color_display(spools):
"""Anexa color_display a cada rolo p/ a coluna Cor da lista (nome informado ou,
na falta, o balde derivado do hexa)."""
out = []
for s in spools:
d = dict(s)
d["color_display"] = (d.get("color_name") or "").strip() or (db.classify_color(d.get("color_hex")) or "")
out.append(d)
return out


@app.route("/spools")
@login_required
def spools_list():
Expand All @@ -43,8 +55,9 @@ def spools_list():
spools = db.list_spools(active_only=active_only)
# IDs recém-cadastrados em lote (?created=1,2,3) — a lista oferece a fila p/ todos.
created_ids = [int(x) for x in request.args.get("created", "").split(",") if x.isdigit()]
return render_template("spools/list.html", spools=spools, active_only=active_only,
q=q, color=color, queue_ids=db.queue_ids(), created_ids=created_ids)
return render_template("spools/list.html", spools=_spools_with_color_display(spools),
active_only=active_only, q=q, color=color,
queue_ids=db.queue_ids(), created_ids=created_ids)


@app.route("/spools/new", methods=["GET", "POST"])
Expand Down Expand Up @@ -84,7 +97,7 @@ def spools_new():
spool_models = db.list_spool_models()
return render_template("spools/form.html", spool=None,
filaments=filaments, spool_models=spool_models,
selected_filament_id=filament_id)
selected_filament_id=filament_id, today=date.today().isoformat())


@app.route("/spools/<int:spool_id>")
Expand Down
2 changes: 1 addition & 1 deletion templates/admin/update.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ <h4 class="fw-bold mb-4">{{ _('Atualizações') }}</h4>
<div class="border rounded p-3 bg-body-tertiary release-notes"
style="max-height:50vh;overflow:auto;font-size:.85rem">
<div class="text-muted mb-2" style="font-size:.8rem">
<i class="bi bi-stars me-1"></i>{{ _('Novidades da v{v}').replace('{v}', latest) }}
<i class="bi bi-stars me-1"></i>{{ _('Novidades (v{de} → v{ate})').replace('{de}', current).replace('{ate}', latest) }}
</div>
{{ release_notes }}
</div>
Expand Down
23 changes: 23 additions & 0 deletions templates/filaments/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,26 @@ <h5 class="fw-semibold mb-3">{{ _('Spools') }} ({{ spools|length }})</h5>
</table>
</div>
</div>
{% if spool_prompt and can_write %}
{# Após cadastrar o filamento: oferece já cadastrar um rolo dele. #}
<div class="modal fade" id="spoolPromptModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-box-seam me-2"></i>{{ _('Cadastrar rolo') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{{ _('Deseja cadastrar um rolo deste filamento agora?') }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{{ _('Agora não') }}</button>
<a href="{{ url_for('spools_new') }}?filament_id={{ filament.id }}" class="btn btn-dark">{{ _('Sim, cadastrar rolo') }}</a>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script nonce="{{ nonce }}">
Expand All @@ -145,5 +165,8 @@ <h5 class="fw-semibold mb-3">{{ _('Spools') }} ({{ spools|length }})</h5>
location.href = row.dataset.href;
});
});
{% if spool_prompt and can_write %}
new bootstrap.Modal(document.getElementById('spoolPromptModal')).show();
{% endif %}
</script>
{% endblock %}
2 changes: 1 addition & 1 deletion templates/spools/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ <h4 class="mb-0 fw-bold">{% if spool %}{{ _('Editar') }} SP-{{ '%04d'|format(spo
<div class="col-md-6">
<label class="form-label fw-semibold">{{ _('Data de Compra') }}</label>
<input type="date" name="purchase_date" class="form-control"
value="{{ spool.purchase_date if spool else '' }}">
value="{{ spool.purchase_date if spool else today }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{{ _('Preço') }}</label>
Expand Down
6 changes: 5 additions & 1 deletion templates/spools/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ <h4 class="mb-0 fw-bold">
<th style="width:44px"></th>
<th data-sort="num">ID</th>
<th data-sort="text">{{ _('Material') }}</th>
<th data-sort="text">{{ _('Cor') }}</th>
<th data-sort="text">{{ _('Marca / Família') }}</th>
<th data-sort="text">{{ _('Local') }}</th>
<th data-sort="num">{{ _('Peso Atual') }}</th>
Expand Down Expand Up @@ -71,6 +72,9 @@ <h4 class="mb-0 fw-bold">
</td>
<td class="fw-bold sc-stack-head">SP-{{ '%04d'|format(s.id) }}</td>
<td data-label="{{ _('Material') }}"><span class="badge bg-secondary">{{ s.material }}</span></td>
<td data-label="{{ _('Cor') }}">
<span class="d-inline-block rounded me-1" style="width:12px;height:12px;background:{{ s.color_hex or '#e0e0e0' }};border:1px solid #ccc;vertical-align:middle"></span>{{ s.color_display or '—' }}
</td>
<td data-label="{{ _('Marca / Família') }}">{{ s.brand }} / {{ s.family }}</td>
<td data-label="{{ _('Local') }}">{{ s.location or '—' }}</td>
<td class="weight-cell" data-label="{{ _('Peso Atual') }}">
Expand Down Expand Up @@ -114,7 +118,7 @@ <h4 class="mb-0 fw-bold">
</td>
</tr>
{% else %}
<tr data-no-result><td colspan="7" class="text-center text-muted py-4">{{ _('Nenhum spool encontrado') }}</td></tr>
<tr data-no-result><td colspan="8" class="text-center text-muted py-4">{{ _('Nenhum spool encontrado') }}</td></tr>
{% endfor %}
</tbody>
</table>
Expand Down
Loading