amora

Pedal Hidrográfico

Static-PWA web map for an urban cycling collective in São Paulo, plus a self-hosted backend, Python automation scripts, and an RDF ontology. Local-first and self-hostable, deliberately built to avoid hard cloud lock-in — the same backend runs standalone on a laptop or self-hosted server via STORAGE_BACKEND=local, with Cloud Run/GCS (STORAGE_BACKEND=gcs) as one optional hosted deploy target, not a dependency.

Repo layout

IRIs são dereferenciáveis (Linked Data) — esquema atual

Todos os IRIs de instância + o vocabulário migraram pra https://id.pedalhidrografi.co/ e DEREFERENCIAM (padrão httpRange-14). A Cloudflare faz um 303 path-preserving de id.pedalhidrografi.co/<path> → amora.pedalhidrografi.co/<path>, onde handlers Flask respondem por content negotiation: Accept: text/turtle (ou ?format=ttl) → as triples do recurso; senão a página/documento humano. Esquema (prefixos usados nos TTLs e no código):

Coisa Prefixo IRI Resolver (amora)
Vocabulário ph: …/terms#<Termo> GET /terms (turtle=ontology.ttl | HTML doc)
Pessoa pes: …/pessoas/<slug8> GET /pessoas/<slug> (turtle | pessoas.html)
Passeio pas: …/passeio/<slug8> GET /passeio/<slug> (turtle | index SSR’ado servido NO path) — aceita também o slug legível (schema:identifier, mintado do título no save e IMUTÁVEL; slug8 com legível disponível 303a pra ele; ?tour= e id numérico legado 303am pra cá). É a URL canônica dos links (compartilhar/sitemap/feed/memória); routes.json espelha em entry.slug
Edição de série (IRI full) …/passeio/<ES>/<seq> (ex.: …/passeio/BP/4) GET /passeio/<es>/<seq> (turtle | 303 pro passeio)
Série ser: …/serie/<ES> (PH/BT/BP/S/SESC) GET /serie/<es> (turtle=série+edições | HTML gerada, edições mais recentes primeiro, linkando pro passeio)
Mídia med: …/midia/<hash16> (opaco — foto OU vídeo) GET /midia/<hash> (turtle | 303 /imagens.html?pick=)
Lista/álbum lst: …/listas/<slug> GET /listas/<slug> (turtle | 303 /imagens/lista/<slug>, o álbum na galeria)
Envio (ph:Upload) env: …/envio/<ts> (sem resolver; provenance interna)

Invariantes: o hash é a IDENTIDADE da mídia e o localname do IRI é ele SOZINHO (med:<hash16>, opaco — sem discriminador image_/video_; uniforme com pessoas/ passeios). O TIPO (foto/vídeo) vem SEMPRE da CLASSE ph:StillImage/ ph:MotionImage, nunca do IRI; os blobs photos/<phash>/… e a dedup dependem do hash. Como phash e vhash compartilham o espaço de 16 hex, o backend tem uma GUARDA cross-type no upload: rejeita um phash que já existe como vídeo (e vice-versa), senão os dois virariam o mesmo IRI. Links de compartilhamento antigos (?p=/?pick=image_<hash>) e /midia/image_<hash> seguem funcionando (o prefixo é tirado na leitura). Nós derivados mantêm o sufixo _ (pas:<slug>_route, med:image_<ph>_geo|_hash) — o purge do backend é aritmética de prefixo str(root)+"_", agnóstica ao formato do IRI. Slug = Crockford base32 (0123456789abcdefghjkmnpqrstvwxyz, 8 chars) pra pessoas e passeios; validador de tour REJEITA _ (colidiria com o nó derivado). Séries e edições são chaves naturais (edição = …/passeio/<ES>/<seq>, realizada por EXATAMENTE um passeio — colisão de numeração vira edição distinta, ex.: BP/3-5; SHACL ph:SeriesEditionShape impõe isso).

phd: = https://pedalhidrografi.co/data/ (host ANTIGO) ainda aparece: só em ph:capturedDuring→pas: (migrado), nos IRIs derivados como convenção, e em menções históricas nos comentários deste arquivo — onde o texto abaixo diz phd:image_/phd:video_/phd:tour_/phd:assoc_, leia med:<hash> (foto E vídeo)/pas:<slug>/<…/passeio/<ES>/<seq>>. phd:org_ (organizadores) NÃO migrou (fora de escopo). A migração foi feita por scripts idempotentes (já removidos — ver git history), com web/data/tour-iri-map.json (id-numérico antigo ↔ slug) baked no container.

Continuidade de deep link + gotcha da Cloudflare. Links antigos ?tour=<id-numérico> são preservados: o worker da Cloudflare que fronteia amora reescreve / → /index.html ANTES de chegar na origem, então index() está registrado em @app.get("/") E @app.get("/index.html") — senão o SSR por passeio e o 303 de alias (?tour=<numid> → ?tour=<slug>, via _tour_iri_map/_legacy_tour_iri) não rodariam via amora. app.js também resolve o alias no cliente (busca /data/tour-iri-map.json sob demanda) como defesa. Guid do feed emite o IRI legado phd:tour_<numid> pra passeios migrados (RSS estável). Resolvers path-based não sofrem com o strip de query (só ?query= era afetado; paths sempre chegam). Só amora.pedalhidrografi.co/?query= era afetado — os IRIs id.…/<path> sempre preservam.

Markdown pra agentes (3ª representação) + descoberta. Toda página HTML negocia também Markdown: Accept: text/markdown (ou ?format=md) devolve a mesma URL em Markdown limpo. _negotiated_format() é o ÚNICO negociador (ttl|md|html; _wants_turtle/_wants_markdown são atalhos) e _markdown_response() monta a resposta (text/markdown, x-markdown-tokens estimado a ~4 chars/token, Content-Signal — env CONTENT_SIGNAL, default igual ao do “Markdown for Agents” da Cloudflare, vazio desliga — e Vary: Accept). Renderers: home = llms.txt + passeios recentes (_render_home_markdown); passeio (_render_tour_markdown, fatos via _tour_facts, compartilhados com o SSR HTML); Memória (_build_memoria_markdown, cache por digest como o HTML); série (_series_rows alimenta HTML e MD), pessoa, mídia, lista (os três leem o _load_catalog() cacheado), /terms (_terms_model alimenta HTML e MD); qualquer outro .html estático vira um resumo — título + descrição + ponteiros pros dados (_html_shell_markdown). HTML segue o default: */* e text/html caem nele; só um Accept que PREFERE markdown/turtle sai dele. Vary: Accept só vai em resposta GERADA (_negotiated()), nunca nos estáticos que o SW pré-cacheia (index/pessoas/imagens.html): o Cache API compara o Accept do request guardado (addAll por string, sem Accept) com o de navegação, e o shell offline pararia de casar. Descoberta (RFC 8288/9727): o after_request _discovery_links põe um Link em toda resposta 200 HTML/Markdown — api-catalog → /.well-known/api-catalog (linkset JSON gerado, api_catalog()), service-desc → web/openapi.json (mantido à mão — atualizar quando um endpoint de leitura mudar), service-doc → llms.txt, describedby → o manifesto VoID, alternate → o feed. Gotcha Cloudflare: se uma cache rule cachear o HTML de / ignorando o Accept, um agente pode receber o HTML cacheado — ou bypassar o cache pra Accept: text/markdown, ou ligar o “Markdown for Agents” da zona (coexiste: a CF só converte HTML e passa o text/markdown da origem intacto).

Architecture

The app is fully static and works offline (service worker). It reads pre-baked routes.json and resolves Turtle dumps via the manifest at web/data/data_graphs.ttl (which currently lists tours.ttl and uploads.ttl). When served by the backend (local or Cloud Run), uploads hit POST /upload-image or POST /upload-video same-origin; on a static-only host (CDN) the form is offline-friendly but uploads have nowhere to go.

Photos and videos are described in RDF/Turtle in the web/data/images.ttl catalog. Both are subclasses of an abstract base ph:Image (“mídia visual”), which carries the shared shape (date, schema:locationCreated→schema:GeoCoordinates, ph:capturedDuring, author, license, provider, schema:isPartOf) via ph:VisualMediaShape (sh:targetClass ph:Image, applied to both subclasses through the validator’s inference="rdfs"). SHACL shapes live in web/data/shapes.ttl:

Nested nodes are minted IRIs, not blank nodes. The schema:GeoCoordinates (schema:locationCreated), nfo:FileHash (nfo:hasHash), and ph:RouteReference (ph:linkRoute) sub-objects use deterministic IRIs derived from the parent — <parent>_geo, _hash, _route (e.g. phd:image_<phash>_geo, phd:tour_1_route). (ph:energyEstimate / ph:measuredEnergy used to mint _energy / _measured qudt:QuantityValue nodes too, but were flattened to plain xsd:decimal kJ literals on the tour — the unit is implicit (kJ) and ph:intensityClassification was dropped, since intensity is derivable from the kJ value by fixed bands and is computed in readers.) The trailing _ keeps siblings distinct (phd:tour_1 never prefix-matches phd:tour_10). This is what makes deletion/merge trivial: purging a subject = removing (subject, *, *) plus the <subject>_* derived IRIs (_derived_subjects / _purge_subject in backend/main.py), with no blank-node-reachability walk. Both the TTL emitters (upload_images.html, upload_tour.html, backfill_tours.html, scripts/build-clips.py) and the validator’s re-upload exclude set rely on this prefix convention. (A one-shot migration, now removed, converted the historical bnode data — git history holds both.)

Key flows:

Clips workflow

Source videos in web/clips/raw/ (.MOV/.mp4/.m4v). Run:

python scripts/build-clips.py

Requires ffmpeg and exiftool in $PATH (Homebrew on macOS, apt on Linux). For each source the script:

The mtime check on the transcoded files makes re-runs cheap. Adding a new clip = drop into raw/ and re-run.

There is no clips.json — that intermediary was removed; build-clips.py writes RDF directly. App.js reads ph:Video from uploads.ttl only.

Conventions — please follow

Verify before finishing

Build & deploy

Open loose ends

Notes