diff --git a/contracts/compatibility.json b/contracts/compatibility.json new file mode 100644 index 0000000..90f6e82 --- /dev/null +++ b/contracts/compatibility.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Version compatibility matrix — ERPLibre platform ↔ erplibre_mobile app", + "updated": "2026-03-29", + "matrix": [ + { + "mobile": "2026.03.29.01", + "erplibre": "1.6.0", + "odoo": "18.0", + "sync_api_version": "1.0", + "modules_required": [ + "project", + "project_todo", + "base_geoengine", + "erplibre_mobile_todo" + ], + "breaking": false, + "notes": "Initial sync support — push/pull project.task (todos), media attachments, geolocation" + } + ], + "modules": { + "project_todo": { + "description": "Odoo built-in To-Do — project.task with project_id=False", + "auto_installed": true, + "source": "odoo/addons/project_todo" + }, + "base_geoengine": { + "description": "OCA geospatial fields — GeoMultiPoint for geolocation entries", + "source": "odoo18.0/addons/OCA_geospatial/base_geoengine", + "version": "18.0.1.2.0", + "python_deps": ["shapely", "geojson"] + }, + "erplibre_mobile_todo": { + "description": "ERPLibre companion module — adds geo_task_point (GeoMultiPoint) to project.task", + "source": "odoo18.0/addons/ERPLibre_erplibre_addons/erplibre_mobile_todo", + "depends": ["project_todo", "base_geoengine"], + "custom_fields_on_project_task": ["geo_task_point"] + } + } +} diff --git a/contracts/note-mapping.md b/contracts/note-mapping.md new file mode 100644 index 0000000..e769e66 --- /dev/null +++ b/contracts/note-mapping.md @@ -0,0 +1,85 @@ +# Note mobile → project.task mapping + +## Field mapping + +| Note mobile field | project.task field | Type | Notes | +|------------------------|-----------------------|------------------|-------| +| *(no mobile id sent)* | `id` | Integer (server) | Odoo ID stored in mobile SQLite as `odoo_id` after first push | +| `title` | `name` | Char | Required | +| `done` | `state` | Selection | `'done'` ↔ done=true ; `'01_in_progress'` ↔ done=false | +| `archived` | `active` | Boolean | false = archived | +| `pinned` | `priority` | Selection | `'1'` = pinned, `'0'` = normal | +| `tags` | `tag_ids` | Many2many | Matched by tag name; created if missing | +| entry `text` | `description` | Html | Each entry → `

text content

` | +| entry `date` | `date_deadline` | Datetime | First date entry only | +| entry `audio` | `attachment_ids` | ir.attachment | + `

🎙️ Enregistrement audio — ISO_DATE

` in description | +| entry `photo` | `attachment_ids` | ir.attachment | + `

📷 Photo — ISO_DATE

` in description | +| entry `video` | `attachment_ids` | ir.attachment | + `

🎥 Vidéo — ISO_DATE

` in description | +| entry `geolocation` | `geo_task_point` | GeoMultiPoint | All lat/lon → MultiPoint GeoJSON ; `

📍 text — lat,lon — ISO_DATE

` in description | + +## Description HTML structure + +Each note's `description` field is built by concatenating all entries in order: + +```html + +

Content of the text entry.

+ + +

Another paragraph of notes.

+ + +

📅 Date : 2026-03-29T14:30:00

+ + +

📍 Géolocalisation : 45.5017, -73.5673 — Bureau principal — 2026-03-29T14:35:00

+ + +

🎙️ Enregistrement audio — 2026-03-29T14:40:00

+ + +

📷 Photo — 2026-03-29T14:45:00

+ + +

🎥 Vidéo — 2026-03-29T14:50:00

+``` + +## GeoMultiPoint format (geo_task_point) + +All geolocation entries of a note are stored as a single GeoJSON MultiPoint: + +```json +{ + "type": "MultiPoint", + "coordinates": [ + [-73.5673, 45.5017], + [-73.5789, 45.4972] + ] +} +``` + +Order matches the order of geolocation entries. Coordinates are `[longitude, latitude]` per GeoJSON spec. +Timestamps and text descriptions are preserved in the HTML description `

` lines. + +## Sync ID strategy + +- Mobile does **not** send its internal UUID to Odoo. +- On first push: `project.task.create()` returns the Odoo `id` (integer). +- Mobile stores it as `odoo_id` in its local SQLite `notes` table. +- Subsequent pushes use `project.task.write([[odoo_id], {...}])`. +- Multiple mobile clients: each independently stores the same `odoo_id` — fully supported. + +## Conflict resolution + +| Condition | Resolution | +|-----------|------------| +| `write_date` (Odoo) > `last_synced_at` (mobile) | Odoo wins — pull overwrites mobile | +| Mobile modified since `last_synced_at`, Odoo not changed | Mobile wins — push | +| Both modified since last sync | Odoo wins (last-write-wins, v1) | + +## Tag resolution + +1. Mobile sends tag names as strings. +2. SyncService calls `project.tags` `search_read` to find existing tags by name. +3. Missing tags: `project.tags.create()` before task create/write. +4. `tag_ids` in task payload uses integer IDs from step 2-3. diff --git a/contracts/odoo-api.json b/contracts/odoo-api.json new file mode 100644 index 0000000..dc813d0 --- /dev/null +++ b/contracts/odoo-api.json @@ -0,0 +1,260 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "API contract — erplibre_mobile ↔ Odoo JSON-RPC 2.0", + "version": "1.0", + "updated": "2026-03-29", + "base_url": "{odoo_url}", + "protocol": "JSON-RPC 2.0", + "content_type": "application/json", + "authentication": { + "method": "session_cookie", + "notes": "Standard Odoo session — no custom auth module required" + }, + "endpoints": { + "auth.login": { + "description": "Authenticate and open a session", + "method": "POST", + "path": "/web/session/authenticate", + "request": { + "jsonrpc": "2.0", + "method": "call", + "params": { + "db": "{database_name}", + "login": "{username}", + "password": "{password}" + } + }, + "response_fields": { + "uid": "integer — user ID, null if auth failed", + "session_id": "string — session cookie value" + }, + "errors": { + "uid=false": "Wrong credentials" + }, + "side_effects": "Sets session cookie — store in SecureStorage" + }, + + "auth.check": { + "description": "Verify session is still valid (ping)", + "method": "POST", + "path": "/web/dataset/call_kw", + "request": { + "jsonrpc": "2.0", + "method": "call", + "params": { + "model": "res.lang", + "method": "search_read", + "args": [], + "kwargs": { "domain": [], "fields": ["name"], "limit": 1 } + } + }, + "errors": { + "session_expired": "HTTP 200 with error code 100 — trigger re-auth" + } + }, + + "tags.resolve": { + "description": "Find or prepare project tags by name", + "method": "POST", + "path": "/web/dataset/call_kw", + "request": { + "params": { + "model": "project.tags", + "method": "search_read", + "args": [], + "kwargs": { + "domain": [["name", "in", ["tag1", "tag2"]]], + "fields": ["id", "name"] + } + } + }, + "notes": "Compare returned names against mobile tags — create missing ones via tags.create" + }, + + "tags.create": { + "description": "Create a missing tag", + "method": "POST", + "path": "/web/dataset/call_kw", + "request": { + "params": { + "model": "project.tags", + "method": "create", + "args": [{ "name": "{tag_name}" }], + "kwargs": {} + } + }, + "response": "integer — new tag ID" + }, + + "notes.pull": { + "description": "Pull todos modified since last sync", + "method": "POST", + "path": "/web/dataset/call_kw", + "request": { + "params": { + "model": "project.task", + "method": "search_read", + "args": [], + "kwargs": { + "domain": [ + ["project_id", "=", false], + ["write_date", ">", "{last_synced_at_iso}"] + ], + "fields": [ + "id", "name", "description", "priority", "active", + "state", "tag_ids", "date_deadline", + "geo_task_point", "write_date", "attachment_ids" + ], + "limit": 100, + "offset": 0, + "order": "write_date asc" + } + } + }, + "notes": "project_id=False = project_todo personal tasks. geo_task_point requires erplibre_mobile_todo installed." + }, + + "notes.poll": { + "description": "Lightweight poll — fetch only IDs and write_date to detect changes", + "method": "POST", + "path": "/web/dataset/call_kw", + "request": { + "params": { + "model": "project.task", + "method": "search_read", + "args": [], + "kwargs": { + "domain": [ + ["project_id", "=", false], + ["write_date", ">", "{last_synced_at_iso}"] + ], + "fields": ["id", "write_date"], + "limit": 200 + } + } + } + }, + + "notes.create": { + "description": "Push a new note to Odoo — returns the Odoo ID to store locally", + "method": "POST", + "path": "/web/dataset/call_kw", + "request": { + "params": { + "model": "project.task", + "method": "create", + "args": [ + { + "name": "{note.title}", + "description": "{note.html_description}", + "project_id": false, + "priority": "1 if pinned else 0", + "active": "not note.archived", + "state": "done if note.done else 01_in_progress", + "tag_ids": [[6, 0, ["{tag_id_1}", "{tag_id_2}"]]], + "date_deadline": "{first_date_entry_iso or null}", + "geo_task_point": "{geojson_multipoint or null}" + } + ], + "kwargs": {} + } + }, + "response": "integer — Odoo task ID → store as odoo_id in mobile SQLite", + "notes": "tag_ids uses Odoo ORM command 6 (replace all). geo_task_point is GeoJSON string." + }, + + "notes.update": { + "description": "Push changes to an existing note", + "method": "POST", + "path": "/web/dataset/call_kw", + "request": { + "params": { + "model": "project.task", + "method": "write", + "args": [ + ["{note.odoo_id}"], + { + "name": "{note.title}", + "description": "{note.html_description}", + "priority": "1 if pinned else 0", + "active": "not note.archived", + "state": "done if note.done else 01_in_progress", + "tag_ids": [[6, 0, ["{tag_id_1}"]]], + "date_deadline": "{first_date_entry_iso or null}", + "geo_task_point": "{geojson_multipoint or null}" + } + ], + "kwargs": {} + } + }, + "response": "true on success" + }, + + "attachments.list": { + "description": "List attachments for a set of tasks", + "method": "POST", + "path": "/web/dataset/call_kw", + "request": { + "params": { + "model": "ir.attachment", + "method": "search_read", + "args": [], + "kwargs": { + "domain": [ + ["res_model", "=", "project.task"], + ["res_id", "in", ["{odoo_id_1}", "{odoo_id_2}"]] + ], + "fields": ["id", "name", "mimetype", "file_size", "create_date", "res_id"] + } + } + } + }, + + "attachments.upload": { + "description": "Upload a media file (audio, photo, video) as task attachment", + "method": "POST", + "path": "/web/binary/upload_attachment", + "content_type": "multipart/form-data", + "request_fields": { + "model": "project.task", + "id": "{note.odoo_id}", + "ufile": "" + }, + "response_fields": { + "id": "integer — attachment ID", + "name": "string — filename" + }, + "notes": "Upload only on WiFi option recommended for video files. Requires valid session cookie." + } + }, + + "re_auth_strategy": { + "trigger": "JSON-RPC error code 100 (session expired) or HTTP 401", + "steps": [ + "1. Retrieve credentials from SecureStorage key 'odoo_sync_credentials_{app_url}'", + "2. Call auth.login with retrieved credentials", + "3. Store new session cookie in SecureStorage key 'odoo_sync_session_{app_url}'", + "4. Retry original request once", + "5. If still failing — set sync_status='error', notify user" + ] + }, + + "sync_status_values": { + "local": "Note exists only on device — never pushed", + "pending": "Modified since last sync — push needed", + "synced": "In sync with Odoo — write_date matches", + "conflict": "Both mobile and Odoo modified since last sync (v1: Odoo wins)", + "error": "Last sync attempt failed — show error to user" + }, + + "sqlite_columns_added": { + "table": "notes", + "migration": "2026032901", + "columns": { + "odoo_id": "INTEGER — Odoo project.task id, null if never pushed", + "odoo_url": "TEXT — Base URL of the Odoo instance used for sync", + "sync_status": "TEXT DEFAULT 'local' — see sync_status_values", + "last_synced_at": "TEXT — ISO 8601 datetime of last successful sync" + } + } +}