diff --git a/docs/openapi/convey-clients.json b/docs/openapi/convey-clients.json index 07472583e..91ebd316c 100644 --- a/docs/openapi/convey-clients.json +++ b/docs/openapi/convey-clients.json @@ -63,6 +63,7 @@ "file_read_failed", "health_report_failed", "identity_busy", + "import_client_id_conflict", "import_conflict", "import_metadata_failed", "import_not_found", @@ -1304,9 +1305,160 @@ ] } }, + "/app/import/api/meta": { + "post": { + "description": "Update allowlisted metadata fields on a not-yet-started import.", + "operationId": "import.meta", + "requestBody": { + "content": { + "application/json": { + "example": { + "facet": "work", + "path": "/journal/imports/20260618_143022/source.m4a" + }, + "schema": { + "additionalProperties": true, + "properties": { + "client": { + "type": "object" + }, + "facet": { + "type": "string" + }, + "imported_via": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "observer_handle": { + "type": "string" + }, + "original_filename": { + "type": "string" + }, + "path": { + "type": "string" + }, + "setting": { + "type": "string" + }, + "source_hint": { + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "path": "/journal/imports/20260618_143022/source.m4a", + "status": "ok", + "timestamp": "20260618_143022", + "updated": { + "facet": "work" + } + }, + "schema": { + "additionalProperties": true, + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "updated": { + "type": "object" + } + }, + "required": [ + "status", + "path", + "timestamp", + "updated" + ], + "type": "object" + } + } + }, + "description": "Import metadata updated." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The import path was missing or the import state is terminal.", + "x-reason-codes": [ + "invalid_operation_for_state", + "missing_required_field" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Access gate rejected a revoked paired-link identity.", + "x-reason-codes": [ + "pl_revoked" + ] + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Import metadata was not found.", + "x-reason-codes": [ + "import_not_found" + ] + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Import metadata could not be read or updated.", + "x-reason-codes": [ + "import_metadata_failed" + ] + } + }, + "summary": "Update import metadata", + "tags": [ + "import" + ] + } + }, "/app/import/api/save": { "post": { - "description": "Save an uploaded import file or pasted text into imports staging. Submit either file or text.", + "description": "Save an uploaded import file or pasted text into imports staging. Submit either file or text. client_item_id is required for idempotent native-client staging.", "operationId": "import.save", "requestBody": { "content": { @@ -1314,6 +1466,12 @@ "schema": { "additionalProperties": true, "properties": { + "client": { + "type": "object" + }, + "client_item_id": { + "type": "string" + }, "deterministic_only": { "type": "boolean" }, @@ -1333,78 +1491,556 @@ "setting": { "type": "string" }, + "source_hint": { + "type": "string" + }, "text": { "type": "string" } }, + "required": [ + "client_item_id" + ], "type": "object" } } }, - "description": "Multipart body with either file or text." + "description": "Multipart body with either file or text.", + "required": true }, "responses": { "200": { "content": { "application/json": { "example": { + "client_item_id": "ios-item-4f8b", + "diagnostics": { + "source_inference": "extension", + "timestamp_detection_method": "upload_fallback", + "timestamp_detection_model_called": false, + "timestamp_detection_no_match_reason": null + }, "facet": null, - "path": "/journal/imports/20260618_143022/source.txt", + "metadata": { + "client": {}, + "imported_via": "ios", + "mime_type": "audio/mp4", + "observer_handle": null, + "original_filename": "source.m4a", + "source_hint": null + }, + "path": "/journal/imports/20260618_143022/source.m4a", + "recommended_action": "start", + "replay": false, + "schema_version": 1, "setting": null, - "timestamp": "20260618_143022", - "timestamp_detection_method": "deterministic", - "timestamp_detection_model_called": false, - "timestamp_detection_no_match_reason": null + "source": "audio", + "status": "staged", + "timestamp": "20260618_143022" }, "schema": { "additionalProperties": true, "properties": { + "client_item_id": { + "type": "string" + }, + "diagnostics": { + "additionalProperties": true, + "properties": { + "source_inference": { + "enum": [ + "extension", + "content_type", + "default" + ], + "type": "string" + }, + "timestamp_detection_method": { + "type": "string" + }, + "timestamp_detection_model_called": { + "type": "boolean" + }, + "timestamp_detection_no_match_reason": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "timestamp_detection_method", + "timestamp_detection_model_called", + "timestamp_detection_no_match_reason", + "source_inference" + ], + "type": "object" + }, + "duplicate": { + "additionalProperties": true, + "properties": { + "entry_count": { + "type": [ + "integer", + "null" + ] + }, + "import_id": { + "type": "string" + }, + "imported_at": { + "type": [ + "string", + "null" + ] + }, + "state": { + "enum": [ + "imported", + "staged" + ], + "type": "string" + } + }, + "required": [ + "import_id", + "imported_at", + "entry_count", + "state" + ], + "type": "object" + }, "facet": { "type": [ "string", "null" ] }, + "metadata": { + "additionalProperties": true, + "properties": { + "client": { + "additionalProperties": true, + "type": "object" + }, + "imported_via": { + "type": [ + "string", + "null" + ] + }, + "mime_type": { + "type": [ + "string", + "null" + ] + }, + "observer_handle": { + "type": [ + "string", + "null" + ] + }, + "original_filename": { + "type": [ + "string", + "null" + ] + }, + "source_hint": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "original_filename", + "mime_type", + "imported_via", + "observer_handle", + "source_hint", + "client" + ], + "type": "object" + }, "path": { "type": "string" }, + "recommended_action": { + "enum": [ + "start", + "do_not_start" + ], + "type": "string" + }, + "replay": { + "type": "boolean" + }, + "schema_version": { + "type": "integer" + }, "setting": { "type": [ "string", "null" ] }, + "source": { + "enum": [ + "audio", + "image", + "document", + "text" + ], + "type": "string" + }, + "status": { + "enum": [ + "staged", + "duplicate" + ], + "type": "string" + }, "timestamp": { "type": "string" + } + }, + "required": [ + "schema_version", + "status", + "replay", + "path", + "timestamp", + "client_item_id", + "source", + "facet", + "setting", + "recommended_action", + "metadata", + "diagnostics" + ], + "type": "object" + } + } + }, + "description": "Import source staged, replayed, or identified as a duplicate." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Required fields were missing or neither file nor text was supplied.", + "x-reason-codes": [ + "ingest_no_files", + "missing_required_field" + ] + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Access gate rejected a revoked paired-link identity.", + "x-reason-codes": [ + "pl_revoked" + ] + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "client_item_id already names different staged content.", + "x-reason-codes": [ + "import_client_id_conflict" + ] + } + }, + "summary": "Save import source", + "tags": [ + "import" + ] + } + }, + "/app/import/api/save-path": { + "post": { + "description": "Register a local filesystem path for import staging using the same idempotent summary response as import.save.", + "operationId": "import.savePath", + "requestBody": { + "content": { + "application/json": { + "example": { + "client": {}, + "client_item_id": "ios-path-1357", + "path": "/Users/sol/Documents/Notes", + "source_hint": "obsidian" + }, + "schema": { + "additionalProperties": true, + "properties": { + "client": { + "type": "object" + }, + "client_item_id": { + "type": "string" + }, + "facet": { + "type": "string" + }, + "imported_via": { + "type": "string" + }, + "observer_handle": { + "type": "string" + }, + "path": { + "type": "string" + }, + "setting": { + "type": "string" + }, + "source_hint": { + "type": "string" + } + }, + "required": [ + "client_item_id", + "path" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "client_item_id": "ios-path-1357", + "diagnostics": { + "source_inference": "extension", + "timestamp_detection_method": "upload_fallback", + "timestamp_detection_model_called": false, + "timestamp_detection_no_match_reason": null + }, + "facet": null, + "metadata": { + "client": {}, + "imported_via": "ios", + "mime_type": null, + "observer_handle": null, + "original_filename": "Notes", + "source_hint": "obsidian" + }, + "path": "/Users/sol/Documents/Notes", + "recommended_action": "start", + "replay": false, + "schema_version": 1, + "setting": null, + "source": "text", + "status": "staged", + "timestamp": "20260618_143022" + }, + "schema": { + "additionalProperties": true, + "properties": { + "client_item_id": { + "type": "string" }, - "timestamp_detection_method": { - "description": "Timestamp detection method: deterministic, model, upload_fallback, or explicit.", + "diagnostics": { + "additionalProperties": true, + "properties": { + "source_inference": { + "enum": [ + "extension", + "content_type", + "default" + ], + "type": "string" + }, + "timestamp_detection_method": { + "type": "string" + }, + "timestamp_detection_model_called": { + "type": "boolean" + }, + "timestamp_detection_no_match_reason": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "timestamp_detection_method", + "timestamp_detection_model_called", + "timestamp_detection_no_match_reason", + "source_inference" + ], + "type": "object" + }, + "duplicate": { + "additionalProperties": true, + "properties": { + "entry_count": { + "type": [ + "integer", + "null" + ] + }, + "import_id": { + "type": "string" + }, + "imported_at": { + "type": [ + "string", + "null" + ] + }, + "state": { + "enum": [ + "imported", + "staged" + ], + "type": "string" + } + }, + "required": [ + "import_id", + "imported_at", + "entry_count", + "state" + ], + "type": "object" + }, + "facet": { + "type": [ + "string", + "null" + ] + }, + "metadata": { + "additionalProperties": true, + "properties": { + "client": { + "additionalProperties": true, + "type": "object" + }, + "imported_via": { + "type": [ + "string", + "null" + ] + }, + "mime_type": { + "type": [ + "string", + "null" + ] + }, + "observer_handle": { + "type": [ + "string", + "null" + ] + }, + "original_filename": { + "type": [ + "string", + "null" + ] + }, + "source_hint": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "original_filename", + "mime_type", + "imported_via", + "observer_handle", + "source_hint", + "client" + ], + "type": "object" + }, + "path": { "type": "string" }, - "timestamp_detection_model_called": { + "recommended_action": { + "enum": [ + "start", + "do_not_start" + ], + "type": "string" + }, + "replay": { "type": "boolean" }, - "timestamp_detection_no_match_reason": { + "schema_version": { + "type": "integer" + }, + "setting": { "type": [ "string", "null" ] + }, + "source": { + "enum": [ + "audio", + "image", + "document", + "text" + ], + "type": "string" + }, + "status": { + "enum": [ + "staged", + "duplicate" + ], + "type": "string" + }, + "timestamp": { + "type": "string" } }, "required": [ + "schema_version", + "status", + "replay", "path", "timestamp", + "client_item_id", + "source", "facet", "setting", - "timestamp_detection_method", - "timestamp_detection_model_called", - "timestamp_detection_no_match_reason" + "recommended_action", + "metadata", + "diagnostics" ], "type": "object" } } }, - "description": "Import source saved." + "description": "Import path staged, replayed, or identified as a duplicate." }, "400": { "content": { @@ -1414,9 +2050,9 @@ } } }, - "description": "Neither file nor text was supplied.", + "description": "client_item_id or path was missing.", "x-reason-codes": [ - "ingest_no_files" + "missing_required_field" ] }, "403": { @@ -1431,9 +2067,35 @@ "x-reason-codes": [ "pl_revoked" ] + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The local path did not exist.", + "x-reason-codes": [ + "file_not_found" + ] + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "client_item_id already names different staged content.", + "x-reason-codes": [ + "import_client_id_conflict" + ] } }, - "summary": "Save import source", + "summary": "Save import source path", "tags": [ "import" ] @@ -1441,7 +2103,7 @@ }, "/app/import/api/start": { "post": { - "description": "Start processing a previously saved import source.", + "description": "Start processing a previously saved import source. Saved import metadata is authoritative for facet, setting, and source routing.", "operationId": "import.start", "requestBody": { "content": { @@ -1449,7 +2111,6 @@ "example": { "force": false, "path": "/journal/imports/20260618_143022/source.txt", - "source": "manual", "timestamp": "20260618_143022" }, "schema": { @@ -1461,9 +2122,6 @@ "path": { "type": "string" }, - "source": { - "type": "string" - }, "timestamp": { "type": "string" } @@ -1514,8 +2172,9 @@ } } }, - "description": "Path or timestamp was missing.", + "description": "Path or timestamp was missing, or the import is terminal.", "x-reason-codes": [ + "invalid_operation_for_state", "missing_required_field" ] }, diff --git a/solstone/apps/import/contract.py b/solstone/apps/import/contract.py index 94491c625..70c9e752b 100644 --- a/solstone/apps/import/contract.py +++ b/solstone/apps/import/contract.py @@ -21,6 +21,110 @@ def _json_error( _NULLABLE_STRING = {"type": ["string", "null"]} +_NULLABLE_INTEGER = {"type": ["integer", "null"]} +_FREE_OBJECT = {"type": "object", "additionalProperties": True} +_STATUS_SCHEMA = {"type": "string", "enum": ["staged", "duplicate"]} +_SOURCE_SCHEMA = {"type": "string", "enum": ["audio", "image", "document", "text"]} +_ACTION_SCHEMA = {"type": "string", "enum": ["start", "do_not_start"]} +_SOURCE_INFERENCE_SCHEMA = { + "type": "string", + "enum": ["extension", "content_type", "default"], +} +_METADATA_SCHEMA = { + "type": "object", + "additionalProperties": True, + "properties": { + "original_filename": _NULLABLE_STRING, + "mime_type": _NULLABLE_STRING, + "imported_via": _NULLABLE_STRING, + "observer_handle": _NULLABLE_STRING, + "source_hint": _NULLABLE_STRING, + "client": _FREE_OBJECT, + }, + "required": [ + "original_filename", + "mime_type", + "imported_via", + "observer_handle", + "source_hint", + "client", + ], +} +_DIAGNOSTICS_SCHEMA = { + "type": "object", + "additionalProperties": True, + "properties": { + "timestamp_detection_method": {"type": "string"}, + "timestamp_detection_model_called": {"type": "boolean"}, + "timestamp_detection_no_match_reason": _NULLABLE_STRING, + "source_inference": _SOURCE_INFERENCE_SCHEMA, + }, + "required": [ + "timestamp_detection_method", + "timestamp_detection_model_called", + "timestamp_detection_no_match_reason", + "source_inference", + ], +} +_DUPLICATE_SCHEMA = { + "type": "object", + "additionalProperties": True, + "properties": { + "import_id": {"type": "string"}, + "imported_at": _NULLABLE_STRING, + "entry_count": _NULLABLE_INTEGER, + "state": {"type": "string", "enum": ["imported", "staged"]}, + }, + "required": ["import_id", "imported_at", "entry_count", "state"], +} + +_SAVE_RESPONSE_FIELDS = ( + FieldSpec("schema_version", "integer", required=True), + FieldSpec("status", "string", required=True, raw_schema=_STATUS_SCHEMA), + FieldSpec("replay", "boolean", required=True), + FieldSpec("path", "string", required=True), + FieldSpec("timestamp", "string", required=True), + FieldSpec("client_item_id", "string", required=True), + FieldSpec("source", "string", required=True, raw_schema=_SOURCE_SCHEMA), + FieldSpec("facet", "string", required=True, raw_schema=_NULLABLE_STRING), + FieldSpec("setting", "string", required=True, raw_schema=_NULLABLE_STRING), + FieldSpec( + "recommended_action", + "string", + required=True, + raw_schema=_ACTION_SCHEMA, + ), + FieldSpec("metadata", "object", required=True, raw_schema=_METADATA_SCHEMA), + FieldSpec("diagnostics", "object", required=True, raw_schema=_DIAGNOSTICS_SCHEMA), + FieldSpec("duplicate", "object", raw_schema=_DUPLICATE_SCHEMA), +) + +_SAVE_RESPONSE_EXAMPLE = { + "schema_version": 1, + "status": "staged", + "replay": False, + "path": "/journal/imports/20260618_143022/source.m4a", + "timestamp": "20260618_143022", + "client_item_id": "ios-item-4f8b", + "source": "audio", + "facet": None, + "setting": None, + "recommended_action": "start", + "metadata": { + "original_filename": "source.m4a", + "mime_type": "audio/mp4", + "imported_via": "ios", + "observer_handle": None, + "source_hint": None, + "client": {}, + }, + "diagnostics": { + "timestamp_detection_method": "upload_fallback", + "timestamp_detection_model_called": False, + "timestamp_detection_no_match_reason": None, + "source_inference": "extension", + }, +} OPERATIONS: list[OperationSpec] = [ @@ -31,11 +135,13 @@ OPERATIONS: list[OperationSpec] = [ summary="Save import source", description=( "Save an uploaded import file or pasted text into imports staging. " - "Submit either file or text." + "Submit either file or text. client_item_id is required for " + "idempotent native-client staging." ), request=RequestSpec( content_type="multipart/form-data", fields=( + FieldSpec("client_item_id", "string", required=True), FieldSpec( "file", "string", @@ -44,66 +150,163 @@ OPERATIONS: list[OperationSpec] = [ FieldSpec("text", "string"), FieldSpec("facet", "string"), FieldSpec("setting", "string"), + FieldSpec("source_hint", "string"), FieldSpec("imported_via", "string"), FieldSpec("observer_handle", "string"), FieldSpec("deterministic_only", "boolean"), + FieldSpec("client", "object"), ), description="Multipart body with either file or text.", ), responses=( ResponseSpec( status=200, - description="Import source saved.", + description=( + "Import source staged, replayed, or identified as a duplicate." + ), + named_fields=_SAVE_RESPONSE_FIELDS, + example=_SAVE_RESPONSE_EXAMPLE, + ), + _json_error( + 400, + ("ingest_no_files", "missing_required_field"), + "Required fields were missing or neither file nor text was supplied.", + ), + _json_error( + 409, + ("import_client_id_conflict",), + "client_item_id already names different staged content.", + ), + _json_error( + 403, + ("pl_revoked",), + "Access gate rejected a revoked paired-link identity.", + ), + ), + ), + OperationSpec( + operation_id="import.savePath", + method="POST", + rule="/app/import/api/save-path", + summary="Save import source path", + description=( + "Register a local filesystem path for import staging using the same " + "idempotent summary response as import.save." + ), + request=RequestSpec( + fields=( + FieldSpec("client_item_id", "string", required=True), + FieldSpec("path", "string", required=True), + FieldSpec("facet", "string"), + FieldSpec("setting", "string"), + FieldSpec("source_hint", "string"), + FieldSpec("imported_via", "string"), + FieldSpec("observer_handle", "string"), + FieldSpec("client", "object"), + ), + example={ + "client_item_id": "ios-path-1357", + "path": "/Users/sol/Documents/Notes", + "source_hint": "obsidian", + "client": {}, + }, + ), + responses=( + ResponseSpec( + status=200, + description=( + "Import path staged, replayed, or identified as a duplicate." + ), + named_fields=_SAVE_RESPONSE_FIELDS, + example={ + **_SAVE_RESPONSE_EXAMPLE, + "path": "/Users/sol/Documents/Notes", + "client_item_id": "ios-path-1357", + "source": "text", + "metadata": { + **_SAVE_RESPONSE_EXAMPLE["metadata"], + "original_filename": "Notes", + "mime_type": None, + "source_hint": "obsidian", + }, + }, + ), + _json_error( + 400, + ("missing_required_field",), + "client_item_id or path was missing.", + ), + _json_error( + 404, + ("file_not_found",), + "The local path did not exist.", + ), + _json_error( + 409, + ("import_client_id_conflict",), + "client_item_id already names different staged content.", + ), + _json_error( + 403, + ("pl_revoked",), + "Access gate rejected a revoked paired-link identity.", + ), + ), + ), + OperationSpec( + operation_id="import.meta", + method="POST", + rule="/app/import/api/meta", + summary="Update import metadata", + description="Update allowlisted metadata fields on a not-yet-started import.", + request=RequestSpec( + fields=( + FieldSpec("path", "string", required=True), + FieldSpec("facet", "string"), + FieldSpec("setting", "string"), + FieldSpec("original_filename", "string"), + FieldSpec("mime_type", "string"), + FieldSpec("source_hint", "string"), + FieldSpec("observer_handle", "string"), + FieldSpec("imported_via", "string"), + FieldSpec("client", "object"), + ), + example={ + "path": "/journal/imports/20260618_143022/source.m4a", + "facet": "work", + }, + ), + responses=( + ResponseSpec( + status=200, + description="Import metadata updated.", named_fields=( + FieldSpec("status", "string", required=True), FieldSpec("path", "string", required=True), FieldSpec("timestamp", "string", required=True), - FieldSpec( - "facet", - "string", - required=True, - raw_schema=_NULLABLE_STRING, - ), - FieldSpec( - "setting", - "string", - required=True, - raw_schema=_NULLABLE_STRING, - ), - FieldSpec( - "timestamp_detection_method", - "string", - required=True, - description=( - "Timestamp detection method: deterministic, model, " - "upload_fallback, or explicit." - ), - ), - FieldSpec( - "timestamp_detection_model_called", - "boolean", - required=True, - ), - FieldSpec( - "timestamp_detection_no_match_reason", - "string", - required=True, - raw_schema=_NULLABLE_STRING, - ), + FieldSpec("updated", "object", required=True), ), example={ - "path": "/journal/imports/20260618_143022/source.txt", + "status": "ok", + "path": "/journal/imports/20260618_143022/source.m4a", "timestamp": "20260618_143022", - "facet": None, - "setting": None, - "timestamp_detection_method": "deterministic", - "timestamp_detection_model_called": False, - "timestamp_detection_no_match_reason": None, + "updated": {"facet": "work"}, }, ), _json_error( 400, - ("ingest_no_files",), - "Neither file nor text was supplied.", + ("invalid_operation_for_state", "missing_required_field"), + "The import path was missing or the import state is terminal.", + ), + _json_error( + 404, + ("import_not_found",), + "Import metadata was not found.", + ), + _json_error( + 500, + ("import_metadata_failed",), + "Import metadata could not be read or updated.", ), _json_error( 403, @@ -117,18 +320,19 @@ OPERATIONS: list[OperationSpec] = [ method="POST", rule="/app/import/api/start", summary="Start import", - description="Start processing a previously saved import source.", + description=( + "Start processing a previously saved import source. Saved import " + "metadata is authoritative for facet, setting, and source routing." + ), request=RequestSpec( fields=( FieldSpec("path", "string", required=True), FieldSpec("timestamp", "string", required=True), - FieldSpec("source", "string"), FieldSpec("force", "boolean"), ), example={ "path": "/journal/imports/20260618_143022/source.txt", "timestamp": "20260618_143022", - "source": "manual", "force": False, }, ), @@ -144,8 +348,8 @@ OPERATIONS: list[OperationSpec] = [ ), _json_error( 400, - ("missing_required_field",), - "Path or timestamp was missing.", + ("invalid_operation_for_state", "missing_required_field"), + "Path or timestamp was missing, or the import is terminal.", ), _json_error( 404, diff --git a/solstone/apps/import/routes.py b/solstone/apps/import/routes.py index b9b811119..da52f55ca 100644 --- a/solstone/apps/import/routes.py +++ b/solstone/apps/import/routes.py @@ -4,9 +4,9 @@ from __future__ import annotations import json -import logging import re import time +from datetime import datetime from pathlib import Path from typing import Any @@ -17,10 +17,12 @@ from solstone.apps.utils import log_app_action from solstone.convey import emit, state from solstone.convey.reasons import ( FILE_NOT_FOUND, + IMPORT_CLIENT_ID_CONFLICT, IMPORT_CONFLICT, IMPORT_METADATA_FAILED, IMPORT_NOT_FOUND, INGEST_NO_FILES, + INVALID_OPERATION_FOR_STATE, INVALID_REQUEST_VALUE, JOURNAL_SOURCE_PROBLEM, MISSING_REQUIRED_FIELD, @@ -32,8 +34,11 @@ from solstone.convey.utils import ( success_response, ) from solstone.think.detect_created import detect_created, resolve_created_deterministic +from solstone.think.importers.shared import find_manifest_by_hash, hash_source from solstone.think.importers.utils import ( build_import_info, + find_staged_by_client_item_id, + find_staged_by_source_hash, generate_content_manifest, get_import_details, list_import_timestamps, @@ -44,7 +49,11 @@ from solstone.think.importers.utils import ( update_import_metadata_fields, write_import_metadata, ) -from solstone.think.media import MEDIA_EXTENSIONS +from solstone.think.media import ( + MEDIA_EXTENSIONS, + canonical_source, + canonical_source_signal, +) from solstone.think.utils import day_path, now_ms from .journal_sources import ( @@ -222,15 +231,233 @@ def _form_bool(value: str | None) -> bool: return value.strip().lower() in {"true", "1", "yes"} if value else False +CANONICAL_IMPORT_SOURCES = {"audio", "image", "document", "text"} + + +def _clean_optional(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + cleaned = value.strip() + else: + cleaned = str(value).strip() + return cleaned or None + + +def _client_bag(value: Any) -> dict: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _build_save_summary( + metadata: dict, + *, + status: str, + replay: bool, + duplicate: dict | None, + recommended_action: str | None = None, +) -> dict: + """Build the versioned import staging summary response.""" + client = metadata.get("client") + if not isinstance(client, dict): + client = {} + action = recommended_action + if action is None: + action = "do_not_start" if status == "duplicate" else "start" + + summary: dict[str, Any] = { + "schema_version": 1, + "status": status, + "replay": replay, + "path": str(metadata.get("file_path", "")), + "timestamp": str(metadata.get("user_timestamp", "")), + "client_item_id": str(metadata.get("client_item_id", "")), + "source": metadata.get("source", "text"), + "facet": metadata.get("facet"), + "setting": metadata.get("setting"), + "recommended_action": action, + "metadata": { + "original_filename": metadata.get("original_filename"), + "mime_type": metadata.get("mime_type"), + "imported_via": metadata.get("imported_via"), + "observer_handle": metadata.get("observer_handle"), + "source_hint": metadata.get("source_hint"), + "client": client, + }, + "diagnostics": { + "timestamp_detection_method": metadata.get( + "timestamp_detection_method", "duplicate" + ), + "timestamp_detection_model_called": metadata.get( + "timestamp_detection_model_called", False + ), + "timestamp_detection_no_match_reason": metadata.get( + "timestamp_detection_no_match_reason" + ), + "source_inference": metadata.get("source_inference", "default"), + }, + } + if duplicate is not None: + summary["duplicate"] = { + "import_id": duplicate.get("import_id"), + "imported_at": duplicate.get("imported_at"), + "entry_count": duplicate.get("entry_count"), + "state": duplicate.get("state"), + } + return summary + + +def _load_import_metadata_or_none(journal_root: Path, timestamp: str) -> dict | None: + try: + metadata = read_import_metadata(journal_root, timestamp) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + return metadata if isinstance(metadata, dict) else None + + +def _duplicate_summary_metadata( + journal_root: Path, + *, + client_item_id: str, + source: str, + source_inference: str, + duplicate: dict, + existing_metadata: dict | None = None, +) -> dict: + import_id = str(duplicate.get("import_id") or "") + metadata = dict(existing_metadata or {}) + if not metadata and import_id: + metadata = dict(_load_import_metadata_or_none(journal_root, import_id) or {}) + + metadata.setdefault("file_path", str(journal_root / "imports" / import_id)) + metadata.setdefault("user_timestamp", import_id) + metadata.setdefault("original_filename", None) + metadata.setdefault("mime_type", None) + metadata.setdefault("imported_via", duplicate.get("imported_via")) + metadata.setdefault("observer_handle", duplicate.get("observer_handle")) + metadata.setdefault("source_hint", None) + metadata.setdefault("client", {}) + metadata.setdefault("facet", None) + metadata.setdefault("setting", None) + metadata.setdefault("timestamp_detection_method", "duplicate") + metadata.setdefault("timestamp_detection_model_called", False) + metadata.setdefault("timestamp_detection_no_match_reason", None) + metadata["client_item_id"] = client_item_id + metadata["source_inference"] = metadata.get("source_inference") or source_inference + if metadata.get("source") not in CANONICAL_IMPORT_SOURCES: + metadata["source"] = source + return metadata + + +def _duplicate_or_replay_response( + journal_root: Path, + *, + client_item_id: str, + source_hash: str, + source: str, + source_inference: str, +) -> Any | None: + existing = find_staged_by_client_item_id(journal_root, client_item_id) + if existing: + if existing.get("source_hash") == source_hash: + replay_action = ( + "do_not_start" + if existing.get("task_id") or existing.get("processing_completed") + else None + ) + return jsonify( + _build_save_summary( + existing, + status="staged", + replay=True, + duplicate=None, + recommended_action=replay_action, + ) + ) + return error_response( + IMPORT_CLIENT_ID_CONFLICT, + detail=( + "client_item_id already staged for different content; use a new " + "client_item_id or re-fetch the existing item" + ), + ) + + imported = find_manifest_by_hash(journal_root, source_hash) + if imported: + duplicate = { + "import_id": imported.get("import_id"), + "imported_at": imported.get("imported_at"), + "entry_count": imported.get("entry_count"), + "state": "imported", + "imported_via": imported.get("imported_via"), + "observer_handle": imported.get("observer_handle"), + } + metadata = _duplicate_summary_metadata( + journal_root, + client_item_id=client_item_id, + source=source, + source_inference=source_inference, + duplicate=duplicate, + ) + return jsonify( + _build_save_summary( + metadata, + status="duplicate", + replay=False, + duplicate=duplicate, + ) + ) + + staged_duplicate = find_staged_by_source_hash(journal_root, source_hash) + if staged_duplicate: + duplicate = { + "import_id": staged_duplicate.get("timestamp"), + "imported_at": None, + "entry_count": None, + "state": "staged", + } + metadata = _duplicate_summary_metadata( + journal_root, + client_item_id=client_item_id, + source=source, + source_inference=source_inference, + duplicate=duplicate, + existing_metadata=staged_duplicate, + ) + return jsonify( + _build_save_summary( + metadata, + status="duplicate", + replay=False, + duplicate=duplicate, + ) + ) + + return None + + @import_bp.route("/api/save", methods=["POST"]) def import_save() -> Any: - from datetime import datetime - upload = request.files.get("file") text = request.form.get("text", "").strip() + client_item_id = request.form.get("client_item_id", "").strip() facet = request.form.get("facet", "").strip() or None setting = request.form.get("setting", "").strip() or None + source_hint = request.form.get("source_hint", "").strip() or None + imported_via = request.form.get("imported_via", "").strip() or "web_dashboard" + observer_handle = request.form.get("observer_handle", "").strip() or None deterministic_only = _form_bool(request.form.get("deterministic_only")) + client = _client_bag(request.form.get("client")) + + if not client_item_id: + return error_response(MISSING_REQUIRED_FIELD, detail="Missing client_item_id") # Generate timestamp for folder name timestamp_ms = now_ms() @@ -243,6 +470,15 @@ def import_save() -> Any: else: return error_response(INGEST_NO_FILES, detail="No input") + original_filename = upload.filename if upload else "paste.txt" + mime_type = upload.content_type if upload else "text/plain" + source = canonical_source(filename=original_filename, content_type=mime_type) + source_inference = canonical_source_signal( + filename=original_filename, + content_type=mime_type, + ) + journal_root = Path(state.journal_root) + # Detect timestamp from content first (need temporary save for detection) ts = None detection_result = None @@ -272,6 +508,18 @@ def import_save() -> Any: temp_path = tmp.name try: + temp_source = Path(temp_path) + source_hash = hash_source(temp_source) + duplicate_or_replay = _duplicate_or_replay_response( + journal_root, + client_item_id=client_item_id, + source_hash=source_hash, + source=source, + source_inference=source_inference, + ) + if duplicate_or_replay is not None: + return duplicate_or_replay + try: original_name = upload.filename if upload else None detection_result = resolve_created_deterministic( @@ -313,111 +561,92 @@ def import_save() -> Any: detection_result = None timestamp_detection_model_called = True timestamp_detection_no_match_reason = "model_no_match" - finally: - # Clean up temporary file - Path(temp_path).unlink(missing_ok=True) - - # Use detected timestamp or fall back to upload timestamp - folder_timestamp = ( - ts - if ts - else f"{datetime.fromtimestamp(timestamp_ms / 1000).strftime('%Y%m%d_%H%M%S')}" - ) - - # Save the actual file using utility function - if upload: - # Save uploaded file to temp location first, then move to import dir - import tempfile - with tempfile.NamedTemporaryFile(delete=False) as tmp: - upload.save(tmp.name) - temp_source = Path(tmp.name) + # Use detected timestamp or fall back to upload timestamp + folder_timestamp = ( + ts + if ts + else datetime.fromtimestamp(timestamp_ms / 1000).strftime("%Y%m%d_%H%M%S") + ) - try: + # Save the actual file using utility function + if upload: file_path = save_import_file( - journal_root=Path(state.journal_root), + journal_root=journal_root, timestamp=folder_timestamp, source_path=temp_source, filename=filename, ) - finally: - temp_source.unlink(missing_ok=True) - else: - file_path = save_import_text( - journal_root=Path(state.journal_root), - timestamp=folder_timestamp, - content=text, - filename=filename, - ) - - # Build metadata dict - metadata = { - "original_filename": upload.filename if upload else "paste.txt", - "upload_timestamp": timestamp_ms, - "upload_datetime": datetime.fromtimestamp(timestamp_ms / 1000).isoformat(), - "detection_result": detection_result, - "detected_timestamp": ts, - "user_timestamp": folder_timestamp, # The timestamp used for the folder - "timestamp_detection_method": timestamp_detection_method, - "timestamp_detection_model_called": timestamp_detection_model_called, - "timestamp_detection_no_match_reason": timestamp_detection_no_match_reason, - "file_size": file_path.stat().st_size if file_path.exists() else 0, - "mime_type": upload.content_type if upload else "text/plain", - "facet": facet, # Include selected facet - "setting": setting, - "file_path": str(file_path), # Store the actual file path - "imported_via": request.form.get("imported_via", "").strip() or "web_dashboard", - "link_id": _link_id_from_identity(), - "observer_handle": request.form.get("observer_handle", "").strip() or None, - } - - # Write metadata using utility function - write_import_metadata( - journal_root=Path(state.journal_root), - timestamp=folder_timestamp, - metadata=metadata, - ) + else: + file_path = save_import_text( + journal_root=journal_root, + timestamp=folder_timestamp, + content=text, + filename=filename, + ) - # Check for dedup — has this exact file been imported before? - dedup = None - try: - from solstone.think.importers.shared import find_manifest_by_hash, hash_source - - source_hash = hash_source(file_path) - existing = find_manifest_by_hash(Path(state.journal_root), source_hash) - if existing: - dedup = { - "imported_at": existing.get("imported_at", "unknown"), - "entry_count": existing.get("entry_count", 0), - "import_id": existing.get("import_id", ""), - } - except OSError as exc: - logging.warning("Dedup check failed for %s: %s", file_path, exc) + # Build metadata dict + metadata = { + "original_filename": original_filename, + "upload_timestamp": timestamp_ms, + "upload_datetime": datetime.fromtimestamp(timestamp_ms / 1000).isoformat(), + "detection_result": detection_result, + "detected_timestamp": ts, + "user_timestamp": folder_timestamp, + "timestamp_detection_method": timestamp_detection_method, + "timestamp_detection_model_called": timestamp_detection_model_called, + "timestamp_detection_no_match_reason": timestamp_detection_no_match_reason, + "source_inference": source_inference, + "file_size": file_path.stat().st_size if file_path.exists() else 0, + "mime_type": mime_type, + "facet": facet, + "setting": setting, + "file_path": str(file_path), + "imported_via": imported_via, + "link_id": _link_id_from_identity(), + "observer_handle": observer_handle, + "client_item_id": client_item_id, + "source_hash": source_hash, + "source": source, + "source_hint": source_hint, + "client": client, + } - result: dict[str, Any] = { - "path": str(file_path), - "timestamp": folder_timestamp, - "facet": facet, - "setting": setting, - "timestamp_detection_method": timestamp_detection_method, - "timestamp_detection_model_called": timestamp_detection_model_called, - "timestamp_detection_no_match_reason": timestamp_detection_no_match_reason, - } - if dedup: - result["dedup"] = dedup + # Write metadata using utility function + write_import_metadata( + journal_root=journal_root, + timestamp=folder_timestamp, + metadata=metadata, + ) - return jsonify(result) + return jsonify( + _build_save_summary( + metadata, + status="staged", + replay=False, + duplicate=None, + ) + ) + finally: + # Clean up temporary file + Path(temp_path).unlink(missing_ok=True) @import_bp.route("/api/save-path", methods=["POST"]) def import_save_path() -> Any: """Register a local filesystem path for import (e.g. Obsidian vault).""" - from datetime import datetime - data = request.get_json(force=True) + client_item_id = data.get("client_item_id", "").strip() local_path = data.get("path", "").strip() facet = data.get("facet", "").strip() or None setting = data.get("setting", "").strip() or None + source_hint = data.get("source_hint", "").strip() or None + imported_via = data.get("imported_via", "").strip() or "web_dashboard" + observer_handle = data.get("observer_handle", "").strip() or None + client = _client_bag(data.get("client")) + + if not client_item_id: + return error_response(MISSING_REQUIRED_FIELD, detail="Missing client_item_id") if not local_path: return error_response(MISSING_REQUIRED_FIELD, detail="Missing path") @@ -431,23 +660,42 @@ def import_save_path() -> Any: f"{datetime.fromtimestamp(timestamp_ms / 1000).strftime('%Y%m%d_%H%M%S')}" ) - # Create import directory and metadata journal_root = Path(state.journal_root) - import_dir = journal_root / "imports" / folder_timestamp - import_dir.mkdir(parents=True, exist_ok=True) + source_hash = hash_source(local) + source = canonical_source(filename=local.name) + source_inference = canonical_source_signal(filename=local.name) + duplicate_or_replay = _duplicate_or_replay_response( + journal_root, + client_item_id=client_item_id, + source_hash=source_hash, + source=source, + source_inference=source_inference, + ) + if duplicate_or_replay is not None: + return duplicate_or_replay metadata = { "original_filename": local.name, "upload_timestamp": timestamp_ms, "upload_datetime": datetime.fromtimestamp(timestamp_ms / 1000).isoformat(), "user_timestamp": folder_timestamp, + "timestamp_detection_method": "path_fallback", + "timestamp_detection_model_called": False, + "timestamp_detection_no_match_reason": None, + "source_inference": source_inference, "file_path": local_path, "facet": facet, "setting": setting, "is_local_path": True, - "imported_via": data.get("imported_via", "").strip() or "web_dashboard", + "mime_type": None, + "imported_via": imported_via, "link_id": _link_id_from_identity(), - "observer_handle": data.get("observer_handle", "").strip() or None, + "observer_handle": observer_handle, + "client_item_id": client_item_id, + "source_hash": source_hash, + "source": source, + "source_hint": source_hint, + "client": client, } write_import_metadata( @@ -457,37 +705,83 @@ def import_save_path() -> Any: ) return jsonify( - { - "path": local_path, - "timestamp": folder_timestamp, - "facet": facet, - "setting": setting, - } + _build_save_summary( + metadata, + status="staged", + replay=False, + duplicate=None, + ) ) -@import_bp.route("/api/facet", methods=["POST"]) +@import_bp.route("/api/meta", methods=["POST"]) def import_update_metadata() -> Any: - """Update stored metadata (facet/setting) for a saved import.""" + """Update stored metadata for a saved import.""" data = request.get_json(force=True) raw_path = data.get("path", "").strip() if not raw_path: return error_response(MISSING_REQUIRED_FIELD, detail="Missing import path") - facet = data.get("facet", "").strip() or None - setting = data.get("setting", "").strip() or None - # Extract timestamp from path # Path format: .../imports/{timestamp}/{filename} file_path = Path(raw_path) timestamp = file_path.parent.name + journal_root = Path(state.journal_root) try: - # Use utility function to update metadata - metadata, updated = update_import_metadata_fields( - journal_root=Path(state.journal_root), + metadata = read_import_metadata(journal_root=journal_root, timestamp=timestamp) + except FileNotFoundError: + return error_response(IMPORT_NOT_FOUND, detail="Import metadata not found") + except Exception as exc: + return error_response( + IMPORT_METADATA_FAILED, + detail=f"Failed to read metadata: {exc}", + ) + + if metadata.get("task_id") or metadata.get("processing_completed"): + return error_response( + INVALID_OPERATION_FOR_STATE, + detail="import already started or processed", + ) + + source_hash = metadata.get("source_hash") + if source_hash and find_manifest_by_hash(journal_root, source_hash): + return error_response( + INVALID_OPERATION_FOR_STATE, + detail="content already imported", + ) + + updates: dict[str, Any] = {} + for key in ( + "facet", + "setting", + "original_filename", + "mime_type", + "source_hint", + "observer_handle", + "imported_via", + "client", + ): + if key not in data: + continue + if key in {"facet", "setting", "source_hint", "observer_handle"}: + updates[key] = _clean_optional(data.get(key)) + elif key == "client": + updates[key] = _client_bag(data.get(key)) + else: + updates[key] = data.get(key) + + changed = { + key: value + for key, value in updates.items() + if key not in metadata or metadata.get(key) != value + } + + try: + update_import_metadata_fields( + journal_root=journal_root, timestamp=timestamp, - updates={"facet": facet, "setting": setting}, + updates=updates, ) except FileNotFoundError: return error_response(IMPORT_NOT_FOUND, detail="Import metadata not found") @@ -500,9 +794,9 @@ def import_update_metadata() -> Any: return jsonify( { "status": "ok", - "facet": facet, - "setting": setting, - "updated": updated, + "path": raw_path, + "timestamp": timestamp, + "updated": changed, } ) @@ -854,14 +1148,10 @@ def import_start() -> Any: data = request.get_json(force=True) path = data.get("path") ts = data.get("timestamp") - source = data.get("source") force = data.get("force", False) if not path or not ts: return error_response(MISSING_REQUIRED_FIELD, detail="missing params") - # Generate task ID - task_id = str(now_ms()) - # Extract original timestamp from path and handle timestamp changes file_path = Path(path) journal_root = Path(state.journal_root) @@ -869,6 +1159,34 @@ def import_start() -> Any: is_local_path = not str(file_path).startswith(str(imports_dir)) original_timestamp = file_path.parent.name if not is_local_path else ts + # Read import metadata before any move. Saved metadata is the authority for + # facet, setting, and source routing. + try: + metadata = read_import_metadata( + journal_root=journal_root, + timestamp=original_timestamp, + ) + except FileNotFoundError: + return error_response( + IMPORT_NOT_FOUND, + detail=f"Import metadata not found for {original_timestamp}", + ) + except Exception as e: + return error_response( + IMPORT_METADATA_FAILED, + detail=f"Failed to read metadata: {str(e)}", + ) + + source_hash = metadata.get("source_hash") + if source_hash and find_manifest_by_hash(journal_root, source_hash): + return error_response( + INVALID_OPERATION_FOR_STATE, + detail="content already imported; will not start", + ) + + # Generate task ID + task_id = str(now_ms()) + # If timestamp changed, move the import directory through the imports/ owner if not is_local_path and original_timestamp != ts: try: @@ -899,20 +1217,6 @@ def import_start() -> Any: # Update file_path in metadata (need to update after reading) # We'll handle this after reading the metadata below - # Read import metadata to get facet and setting - try: - metadata = read_import_metadata(journal_root=journal_root, timestamp=ts) - except FileNotFoundError: - return error_response( - IMPORT_NOT_FOUND, - detail=f"Import metadata not found for {ts}", - ) - except Exception as e: - return error_response( - IMPORT_METADATA_FAILED, - detail=f"Failed to read metadata: {str(e)}", - ) - # Update file_path in metadata if timestamp changed if not is_local_path and original_timestamp != ts: try: @@ -921,6 +1225,7 @@ def import_start() -> Any: timestamp=ts, updates={"file_path": path}, ) + metadata["file_path"] = path except Exception as e: return error_response( IMPORT_METADATA_FAILED, @@ -929,6 +1234,7 @@ def import_start() -> Any: facet = metadata.get("facet") setting = metadata.get("setting") + source_hint = _clean_optional(metadata.get("source_hint")) # Build command cmd = ["journal", "importer", path, ts] @@ -936,8 +1242,8 @@ def import_start() -> Any: cmd.extend(["--facet", facet]) if setting: cmd.extend(["--setting", setting]) - if source: - cmd.extend(["--source", source]) + if source_hint: + cmd.extend(["--source", source_hint]) if force: cmd.append("--force") @@ -946,7 +1252,7 @@ def import_start() -> Any: update_import_metadata_fields( journal_root=journal_root, timestamp=ts, - updates={"task_id": task_id, "source": source}, + updates={"task_id": task_id, "source_hint": source_hint}, ) except Exception as e: return error_response( diff --git a/solstone/apps/import/workspace.html b/solstone/apps/import/workspace.html index 42f8f743a..54ba768cb 100644 --- a/solstone/apps/import/workspace.html +++ b/solstone/apps/import/workspace.html @@ -669,6 +669,7 @@ +