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": "