feat(tools): 2.0 phase 5 wave 4 - office, ebooks, data, archives (14 tools) (#224)

This commit is contained in:
SnapOtter
2026-06-13 10:19:11 +08:00
parent 638288e196
commit fc7c1f850e
93 changed files with 7636 additions and 817 deletions
@@ -1,67 +1,54 @@
{
"id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90",
"prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335",
"version": "7",
"dialect": "postgresql",
"version": "6",
"dialect": "sqlite",
"id": "c7909605-aabf-4832-8ef8-9390c0f7c99a",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"public.api_keys": {
"api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'Default API Key'"
},
"permissions": {
"name": "permissions",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
@@ -78,423 +65,120 @@
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"checkConstraints": {}
},
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"actor_username": {
"name": "actor_username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"target_type": {
"name": "target_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"target_id": {
"name": "target_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"details": {
"name": "details",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"audit_log_actor_id_users_id_fk": {
"name": "audit_log_actor_id_users_id_fk",
"tableFrom": "audit_log",
"tableTo": "users",
"columnsFrom": ["actor_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.jobs": {
"jobs": {
"name": "jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tool_id": {
"name": "tool_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pool": {
"name": "pool",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "job_status",
"typeSchema": "public",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'queued'"
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"progress": {
"name": "progress",
"type": "jsonb",
"type": "real",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false,
"default": 0
},
"input_refs": {
"name": "input_refs",
"type": "jsonb",
"input_files": {
"name": "input_files",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"output_refs": {
"name": "output_refs",
"type": "jsonb",
"output_path": {
"name": "output_path",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "jsonb",
"type": "text",
"primaryKey": false,
"notNull": false
},
"bytes_in": {
"name": "bytes_in",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"bytes_out": {
"name": "bytes_out",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"jobs_created_at_idx": {
"name": "jobs_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"jobs_status_idx": {
"name": "jobs_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"jobs_user_id_users_id_fk": {
"name": "jobs_user_id_users_id_fk",
"tableFrom": "jobs",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pipelines": {
"name": "pipelines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"steps": {
"name": "steps",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"pipelines_user_id_users_id_fk": {
"name": "pipelines_user_id_users_id_fk",
"tableFrom": "pipelines",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"checkConstraints": {}
},
"public.roles": {
"name": "roles",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"permissions": {
"name": "permissions",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"is_builtin": {
"name": "is_builtin",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"roles_created_by_users_id_fk": {
"name": "roles_created_by_users_id_fk",
"tableFrom": "roles",
"tableTo": "users",
"columnsFrom": ["created_by"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"roles_name_unique": {
"name": "roles_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
@@ -511,297 +195,115 @@
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"checkConstraints": {}
},
"public.settings": {
"settings": {
"name": "settings",
"schema": "",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.teams": {
"name": "teams",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"teams_name_unique": {
"name": "teams_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_files": {
"name": "user_files",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"original_name": {
"name": "original_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"stored_name": {
"name": "stored_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tool_chain": {
"name": "tool_chain",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_files_user_id_users_id_fk": {
"name": "user_files_user_id_users_id_fk",
"tableFrom": "user_files",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"checkConstraints": {}
},
"public.users": {
"users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"team": {
"name": "team",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'Default'"
},
"must_change_password": {
"name": "must_change_password",
"type": "boolean",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"auth_provider": {
"name": "auth_provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'local'"
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"analytics_enabled": {
"name": "analytics_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"analytics_consent_shown_at": {
"name": "analytics_consent_shown_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"analytics_consent_remind_at": {
"name": "analytics_consent_remind_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": ["username"]
"columns": ["username"],
"isUnique": true
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"enums": {
"public.job_status": {
"name": "job_status",
"schema": "public",
"values": ["queued", "processing", "completed", "failed", "canceled"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"enums": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
@@ -1,54 +1,67 @@
{
"version": "6",
"dialect": "sqlite",
"id": "91a14a95-bbcb-46ef-abe3-6d2f6fbc8458",
"prevId": "c7909605-aabf-4832-8ef8-9390c0f7c99a",
"id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90",
"prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335",
"version": "7",
"dialect": "postgresql",
"tables": {
"api_keys": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'Default API Key'"
},
"permissions": {
"name": "permissions",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"last_used_at": {
"name": "last_used_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"autoincrement": false
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@@ -65,165 +78,423 @@
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"jobs": {
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"actor_username": {
"name": "actor_username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"target_type": {
"name": "target_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"target_id": {
"name": "target_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"details": {
"name": "details",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"audit_log_actor_id_users_id_fk": {
"name": "audit_log_actor_id_users_id_fk",
"tableFrom": "audit_log",
"tableTo": "users",
"columnsFrom": ["actor_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.jobs": {
"name": "jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'queued'"
},
"progress": {
"name": "progress",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"input_files": {
"name": "input_files",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"output_path": {
"name": "output_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"pipelines": {
"name": "pipelines",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"steps": {
"name": "steps",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": false
},
"expires_at": {
"name": "expires_at",
"tool_id": {
"name": "tool_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pool": {
"name": "pool",
"type": "text",
"primaryKey": false,
"notNull": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "job_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"default": 0
},
"progress": {
"name": "progress",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"input_refs": {
"name": "input_refs",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"output_refs": {
"name": "output_refs",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"bytes_in": {
"name": "bytes_in",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"bytes_out": {
"name": "bytes_out",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"jobs_created_at_idx": {
"name": "jobs_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"jobs_status_idx": {
"name": "jobs_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"jobs_user_id_users_id_fk": {
"name": "jobs_user_id_users_id_fk",
"tableFrom": "jobs",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pipelines": {
"name": "pipelines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"steps": {
"name": "steps",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"pipelines_user_id_users_id_fk": {
"name": "pipelines_user_id_users_id_fk",
"tableFrom": "pipelines",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.roles": {
"name": "roles",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"default": "''"
},
"permissions": {
"name": "permissions",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"is_builtin": {
"name": "is_builtin",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"roles_created_by_users_id_fk": {
"name": "roles_created_by_users_id_fk",
"tableFrom": "roles",
"tableTo": "users",
"columnsFrom": ["created_by"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"roles_name_unique": {
"name": "roles_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
@@ -240,115 +511,297 @@
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"settings": {
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"users": {
"name": "users",
"public.teams": {
"name": "teams",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"teams_name_unique": {
"name": "teams_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_files": {
"name": "user_files",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"original_name": {
"name": "original_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"stored_name": {
"name": "stored_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"default": 1
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tool_chain": {
"name": "tool_chain",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"user_files_user_id_users_id_fk": {
"name": "user_files_user_id_users_id_fk",
"tableFrom": "user_files",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"team": {
"name": "team",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'Default'"
},
"must_change_password": {
"name": "must_change_password",
"type": "integer",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"auth_provider": {
"name": "auth_provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'local'"
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"autoincrement": false
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": ["username"],
"isUnique": true
"notNull": true
},
"analytics_enabled": {
"name": "analytics_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"analytics_consent_shown_at": {
"name": "analytics_consent_shown_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"analytics_consent_remind_at": {
"name": "analytics_consent_remind_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": ["username"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
"enums": {
"public.job_status": {
"name": "job_status",
"schema": "public",
"values": ["queued", "processing", "completed", "failed", "canceled"]
}
},
"internal": {
"indexes": {}
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+2
View File
@@ -55,6 +55,7 @@
"sharp": "^0.34.5",
"tar": "^7.5.16",
"tsx": "^4.22.4",
"yauzl": "^3.4.0",
"zod": "^3.24.0",
"zxing-wasm": "^3.1.0"
},
@@ -70,6 +71,7 @@
"@types/potrace": "^2.1.5",
"@types/qrcode": "^1.5.6",
"@types/tar": "^7.0.87",
"@types/yauzl": "^3.3.0",
"drizzle-kit": "^0.31.0",
"typescript": "^5.7.0"
},
@@ -0,0 +1,57 @@
import { writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const CONTENT_TYPES: Record<string, string> = {
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
odt: "application/vnd.oasis.opendocument.text",
rtf: "application/rtf",
txt: "text/plain",
};
const settingsSchema = z.object({
format: z.enum(["docx", "odt", "rtf", "txt"]),
});
export function registerConvertDocument(app: FastifyInstance) {
createToolRoute(app, {
toolId: "convert-document",
settingsSchema,
process: async () => {
throw new Error("convert-document is v2-only");
},
processV2: async (ctx) => {
const settings = ctx.settings as z.infer<typeof settingsSchema>;
const input = ctx.inputs[0];
// Reject same-format no-ops
const inputExt = extname(input.filename).toLowerCase();
if (inputExt === `.${settings.format}`) {
throw new InputValidationError("The file is already in that format", 422);
}
const base = input.filename.replace(/\.[^.]+$/, "");
// Preserve the real extension so LibreOffice can sniff the input format.
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Converting");
const outPath = await convertDocument(inPath, ctx.scratchDir, settings.format, {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.${settings.format}`,
contentType: CONTENT_TYPES[settings.format],
};
},
});
}
@@ -0,0 +1,55 @@
import { writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const CONTENT_TYPES: Record<string, string> = {
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
odp: "application/vnd.oasis.opendocument.presentation",
};
const settingsSchema = z.object({
format: z.enum(["pptx", "odp"]),
});
export function registerConvertPresentation(app: FastifyInstance) {
createToolRoute(app, {
toolId: "convert-presentation",
settingsSchema,
process: async () => {
throw new Error("convert-presentation is v2-only");
},
processV2: async (ctx) => {
const settings = ctx.settings as z.infer<typeof settingsSchema>;
const input = ctx.inputs[0];
// Reject same-format no-ops
const inputExt = extname(input.filename).toLowerCase();
if (inputExt === `.${settings.format}`) {
throw new InputValidationError("The file is already in that format", 422);
}
const base = input.filename.replace(/\.[^.]+$/, "");
// Preserve the real extension so LibreOffice can sniff the input format.
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Converting");
const outPath = await convertDocument(inPath, ctx.scratchDir, settings.format, {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.${settings.format}`,
contentType: CONTENT_TYPES[settings.format],
};
},
});
}
@@ -0,0 +1,56 @@
import { writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const CONTENT_TYPES: Record<string, string> = {
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ods: "application/vnd.oasis.opendocument.spreadsheet",
csv: "text/csv",
};
const settingsSchema = z.object({
format: z.enum(["xlsx", "ods", "csv"]),
});
export function registerConvertSpreadsheet(app: FastifyInstance) {
createToolRoute(app, {
toolId: "convert-spreadsheet",
settingsSchema,
process: async () => {
throw new Error("convert-spreadsheet is v2-only");
},
processV2: async (ctx) => {
const settings = ctx.settings as z.infer<typeof settingsSchema>;
const input = ctx.inputs[0];
// Reject same-format no-ops
const inputExt = extname(input.filename).toLowerCase();
if (inputExt === `.${settings.format}`) {
throw new InputValidationError("The file is already in that format", 422);
}
const base = input.filename.replace(/\.[^.]+$/, "");
// Preserve the real extension so LibreOffice can sniff the input format.
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Converting");
const outPath = await convertDocument(inPath, ctx.scratchDir, settings.format, {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.${settings.format}`,
contentType: CONTENT_TYPES[settings.format],
};
},
});
}
+62
View File
@@ -0,0 +1,62 @@
import { createWriteStream } from "node:fs";
import { extname, join } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerCreateZip(app: FastifyInstance) {
createToolRoute(app, {
toolId: "create-zip",
maxInputs: 50,
settingsSchema,
process: async () => {
throw new Error("create-zip is v2-only");
},
processV2: async (ctx) => {
if (ctx.inputs.length < 2) {
throw new InputValidationError("Zipping needs at least two files");
}
// Deduplicate filenames: name-1.ext, name-2.ext on collision
const usedNames = new Map<string, number>();
const entryNames: string[] = [];
for (const input of ctx.inputs) {
const ext = extname(input.filename);
const base = input.filename.slice(0, input.filename.length - ext.length) || "file";
const key = input.filename.toLowerCase();
const count = usedNames.get(key) ?? 0;
if (count === 0) {
entryNames.push(input.filename);
} else {
entryNames.push(`${base}-${count}${ext}`);
}
usedNames.set(key, count + 1);
}
const zipPath = join(ctx.scratchDir, "archive.zip");
await new Promise<void>((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver("zip", { zlib: { level: 6 } });
output.on("close", () => resolve());
archive.on("error", (err: Error) => reject(err));
archive.pipe(output);
for (let i = 0; i < ctx.inputs.length; i++) {
archive.append(ctx.inputs[i].buffer, { name: entryNames[i] });
const pct = Math.min(90, 10 + Math.round(((i + 1) / ctx.inputs.length) * 80));
ctx.report(pct, `Adding file ${i + 1} of ${ctx.inputs.length}`);
}
void archive.finalize();
});
return {
scratchPath: zipPath,
filename: "archive.zip",
contentType: "application/zip",
};
},
});
}
+65
View File
@@ -0,0 +1,65 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { htmlToPdfPy, runPandoc } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const CONTENT_TYPES: Record<string, string> = {
pdf: "application/pdf",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
html: "text/html",
md: "text/markdown",
};
const settingsSchema = z.object({
format: z.enum(["pdf", "docx", "html", "md"]),
});
export function registerEpubConvert(app: FastifyInstance) {
createToolRoute(app, {
toolId: "epub-convert",
settingsSchema,
process: async () => {
throw new Error("epub-convert is v2-only");
},
processV2: async (ctx) => {
const settings = ctx.settings as z.infer<typeof settingsSchema>;
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const { format } = settings;
ctx.report(10, "Converting");
if (format === "pdf") {
// Two-step chain: epub -> standalone HTML -> PDF via WeasyPrint.
// Resource embedding MUST stay OFF: pandoc --self-contained / --embed-resources
// would fetch remote refs server-side (SSRF). The weasyprint bridge pre-scan
// enforces remote-ref rejection so the PDF path fails safely on remote content.
const intermediateHtml = join(ctx.scratchDir, "book.html");
await runPandoc(inPath, intermediateHtml, { extraArgs: ["--standalone"] });
const outPath = join(ctx.scratchDir, `${base}.pdf`);
await htmlToPdfPy(intermediateHtml, outPath, "html");
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.pdf`,
contentType: CONTENT_TYPES.pdf,
};
}
// Direct pandoc conversion for docx, html, md
const outPath = join(ctx.scratchDir, `${base}.${format}`);
await runPandoc(inPath, outPath, format === "html" ? { extraArgs: ["--standalone"] } : {});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.${format}`,
contentType: CONTENT_TYPES[format],
};
},
});
}
+40
View File
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerExcelToPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "excel-to-pdf",
settingsSchema,
process: async () => {
throw new Error("excel-to-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
// Sanitize the basename but keep the real extension so LibreOffice
// can sniff the input format (e.g. .xlsx vs .ods vs .csv).
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Converting");
const outPath = await convertDocument(inPath, ctx.scratchDir, "pdf", {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.pdf`,
contentType: "application/pdf",
};
},
});
}
+196
View File
@@ -0,0 +1,196 @@
import { createWriteStream } from "node:fs";
import { basename, extname, join } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { type Entry, fromBuffer, type ZipFile } from "yauzl";
import { z } from "zod";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
/** Promise wrapper for yauzl.fromBuffer with lazyEntries. */
function openZipBuffer(buffer: Buffer): Promise<ZipFile> {
return new Promise((resolve, reject) => {
fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (err, zipfile) => {
if (err) return reject(err);
if (!zipfile) return reject(new Error("Failed to open zip"));
resolve(zipfile);
});
});
}
const MAX_ENTRIES = 1000;
const MAX_ENTRY_SIZE = 200 * 1024 * 1024; // 200 MiB
const MAX_TOTAL_SIZE = 500 * 1024 * 1024; // 500 MiB
const MAX_RATIO = 100;
const PAYLOAD_ENTRY_CAP = 100;
const settingsSchema = z.object({});
/** Read all entries from a yauzl ZipFile (lazyEntries mode). */
function collectEntries(zipfile: ZipFile): Promise<Entry[]> {
return new Promise((resolve, reject) => {
const entries: Entry[] = [];
zipfile.on("entry", (entry: Entry) => {
entries.push(entry);
zipfile.readEntry();
});
zipfile.on("end", () => resolve(entries));
zipfile.on("error", (err: Error) => reject(err));
zipfile.readEntry();
});
}
/** Read an entry stream into a Buffer. */
function readEntryBuffer(zipfile: ZipFile, entry: Entry): Promise<Buffer> {
return new Promise((resolve, reject) => {
zipfile.openReadStream(entry, (err, stream) => {
if (err) return reject(err);
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", (e: Error) => reject(e));
});
});
}
/** Deduplicate basenames: name-1.ext, name-2.ext on collision. */
function deduplicateNames(names: string[]): string[] {
const usedNames = new Map<string, number>();
const result: string[] = [];
for (const raw of names) {
const name = basename(raw);
const ext = extname(name);
const base = name.slice(0, name.length - ext.length) || "file";
const key = name.toLowerCase();
const count = usedNames.get(key) ?? 0;
if (count === 0) {
result.push(name);
} else {
result.push(`${base}-${count}${ext}`);
}
usedNames.set(key, count + 1);
}
return result;
}
export function registerExtractZip(app: FastifyInstance) {
createToolRoute(app, {
toolId: "extract-zip",
settingsSchema,
process: async () => {
throw new Error("extract-zip is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const inputBase = input.filename.replace(/\.[^.]+$/, "") || "archive";
const zipSize = input.buffer.length;
ctx.report(5, "Reading archive");
const zipfile = await openZipBuffer(input.buffer);
const allEntries = await collectEntries(zipfile);
// Filter out directories and symlinks
const fileEntries: Entry[] = [];
for (const entry of allEntries) {
// Skip directory entries (name ends with /)
if (entry.fileName.endsWith("/")) continue;
// Skip symlinks: Unix external attrs mode check
const mode = (entry.externalFileAttributes >>> 16) & 0o170000;
if (mode === 0o120000) continue;
fileEntries.push(entry);
}
// Guard: entry count
if (fileEntries.length > MAX_ENTRIES) {
throw new InputValidationError("Too many entries");
}
// Guard: per-entry size, total size, path safety
let totalUncompressed = 0;
for (const entry of fileEntries) {
if (entry.uncompressedSize > MAX_ENTRY_SIZE) {
throw new InputValidationError("Archive expands too large");
}
totalUncompressed += entry.uncompressedSize;
if (totalUncompressed > MAX_TOTAL_SIZE) {
throw new InputValidationError("Archive expands too large");
}
// Path safety: reject absolute paths and ".." segments
const name = entry.fileName;
if (name.startsWith("/") || name.startsWith("\\")) {
throw new InputValidationError("Unsafe entry path");
}
const segments = name.split(/[/\\]/);
if (segments.some((s) => s === "..")) {
throw new InputValidationError("Unsafe entry path");
}
}
// Guard: compression ratio
if (zipSize > 0 && totalUncompressed / zipSize > MAX_RATIO) {
throw new InputValidationError("Suspicious compression ratio");
}
ctx.report(20, "Extracting files");
// Build the entries payload (capped at 100)
const entriesPayload = fileEntries.slice(0, PAYLOAD_ENTRY_CAP).map((e) => ({
name: basename(e.fileName),
size: e.uncompressedSize,
}));
// Re-open for streaming extraction (yauzl consumed the entries)
const zipfile2 = await openZipBuffer(input.buffer);
const allEntries2 = await collectEntries(zipfile2);
const fileEntries2 = allEntries2.filter((entry) => {
if (entry.fileName.endsWith("/")) return false;
const mode = (entry.externalFileAttributes >>> 16) & 0o170000;
if (mode === 0o120000) return false;
return true;
});
// Single file: output the bare file directly
if (fileEntries2.length === 1) {
const entry = fileEntries2[0];
const buf = await readEntryBuffer(zipfile2, entry);
ctx.report(90, "Done");
return {
buffer: buf,
filename: basename(entry.fileName),
contentType: "application/octet-stream",
resultPayload: { entries: entriesPayload },
};
}
// Multiple files: repackage flat
const dedupedNames = deduplicateNames(fileEntries2.map((e) => e.fileName));
const outPath = join(ctx.scratchDir, `${inputBase}_extracted.zip`);
const archive = archiver("zip", { zlib: { level: 6 } });
const output = createWriteStream(outPath);
archive.pipe(output);
for (let i = 0; i < fileEntries2.length; i++) {
const buf = await readEntryBuffer(zipfile2, fileEntries2[i]);
archive.append(buf, { name: dedupedNames[i] });
const pct = Math.min(85, 30 + Math.round(((i + 1) / fileEntries2.length) * 55));
ctx.report(pct, `Extracting file ${i + 1} of ${fileEntries2.length}`);
}
await new Promise<void>((resolve, reject) => {
output.on("close", () => resolve());
archive.on("error", (err: Error) => reject(err));
void archive.finalize();
});
ctx.report(95, "Done");
return {
scratchPath: outPath,
filename: `${inputBase}_extracted.zip`,
contentType: "application/zip",
resultPayload: { entries: entriesPayload },
};
},
});
}
+28
View File
@@ -29,7 +29,11 @@ import { registerCompressVideo } from "./compress-video.js";
import { registerContentAwareResize } from "./content-aware-resize.js";
import { registerConvert } from "./convert.js";
import { registerConvertAudio } from "./convert-audio.js";
import { registerConvertDocument } from "./convert-document.js";
import { registerConvertPresentation } from "./convert-presentation.js";
import { registerConvertSpreadsheet } from "./convert-spreadsheet.js";
import { registerConvertVideo } from "./convert-video.js";
import { registerCreateZip } from "./create-zip.js";
import { registerCrop } from "./crop.js";
import { registerCropPdf } from "./crop-pdf.js";
import { registerCropVideo } from "./crop-video.js";
@@ -38,10 +42,13 @@ import { registerCsvJson } from "./csv-json.js";
import { registerEditMetadata } from "./edit-metadata.js";
import { registerEmbedSubtitles } from "./embed-subtitles.js";
import { registerEnhanceFaces } from "./enhance-faces.js";
import { registerEpubConvert } from "./epub-convert.js";
import { registerEraseObject } from "./erase-object.js";
import { registerExcelToPdf } from "./excel-to-pdf.js";
import { registerExtractAudio } from "./extract-audio.js";
import { registerExtractPages } from "./extract-pages.js";
import { registerExtractSubtitles } from "./extract-subtitles.js";
import { registerExtractZip } from "./extract-zip.js";
import { registerFadeAudio } from "./fade-audio.js";
import { registerFavicon } from "./favicon.js";
import { registerFindDuplicates } from "./find-duplicates.js";
@@ -58,9 +65,12 @@ import { registerImagesToVideo } from "./images-to-video.js";
import { registerInfo } from "./info.js";
import { registerJsonXml } from "./json-xml.js";
import { registerLinearizePdf } from "./linearize-pdf.js";
import { registerMarkdownToDocx } from "./markdown-to-docx.js";
import { registerMarkdownToHtml } from "./markdown-to-html.js";
import { registerMarkdownToPdf } from "./markdown-to-pdf.js";
import { registerMemeGenerator } from "./meme-generator.js";
import { registerMergeAudio } from "./merge-audio.js";
import { registerMergeCsvs } from "./merge-csvs.js";
import { registerMergePdf } from "./merge-pdf.js";
import { registerMergeVideos } from "./merge-videos.js";
import { registerMuteVideo } from "./mute-video.js";
@@ -79,6 +89,7 @@ import { registerPdfToText } from "./pdf-to-text.js";
import { registerPdfToWord } from "./pdf-to-word.js";
import { registerPdfaConvert } from "./pdfa-convert.js";
import { registerPitchShift } from "./pitch-shift.js";
import { registerPowerpointToPdf } from "./powerpoint-to-pdf.js";
import { registerProtectPdf } from "./protect-pdf.js";
import { registerQrGenerate } from "./qr-generate.js";
import { registerRedEyeRemoval } from "./red-eye-removal.js";
@@ -109,6 +120,7 @@ import { registerStitch } from "./stitch.js";
import { registerStripMetadata } from "./strip-metadata.js";
import { registerSvgToRaster } from "./svg-to-raster.js";
import { registerTextOverlay } from "./text-overlay.js";
import { registerToEpub } from "./to-epub.js";
import { registerTransparencyFixer } from "./transparency-fixer.js";
import { registerTrimAudio } from "./trim-audio.js";
import { registerTrimVideo } from "./trim-video.js";
@@ -129,6 +141,8 @@ import { registerWatermarkText } from "./watermark-text.js";
import { registerWatermarkVideo } from "./watermark-video.js";
import { registerWaveformImage } from "./waveform-image.js";
import { registerWordToPdf } from "./word-to-pdf.js";
import { registerXmlToCsv } from "./xml-to-csv.js";
import { registerYamlJson } from "./yaml-json.js";
/**
* Registry that imports and registers all tool routes.
@@ -262,6 +276,11 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "waveform-image", register: registerWaveformImage },
// PDF & Documents
{ id: "convert-document", register: registerConvertDocument },
{ id: "convert-presentation", register: registerConvertPresentation },
{ id: "convert-spreadsheet", register: registerConvertSpreadsheet },
{ id: "epub-convert", register: registerEpubConvert },
{ id: "excel-to-pdf", register: registerExcelToPdf },
{ id: "merge-pdf", register: registerMergePdf },
{ id: "split-pdf", register: registerSplitPdf },
{ id: "compress-pdf", register: registerCompressPdf },
@@ -286,14 +305,23 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "pdf-to-text", register: registerPdfToText },
{ id: "pdf-to-word", register: registerPdfToWord },
{ id: "pdf-metadata", register: registerPdfMetadata },
{ id: "powerpoint-to-pdf", register: registerPowerpointToPdf },
{ id: "html-to-pdf", register: registerHtmlToPdf },
{ id: "markdown-to-docx", register: registerMarkdownToDocx },
{ id: "markdown-to-html", register: registerMarkdownToHtml },
{ id: "markdown-to-pdf", register: registerMarkdownToPdf },
{ id: "to-epub", register: registerToEpub },
// Data Files
{ id: "create-zip", register: registerCreateZip },
{ id: "csv-excel", register: registerCsvExcel },
{ id: "csv-json", register: registerCsvJson },
{ id: "extract-zip", register: registerExtractZip },
{ id: "json-xml", register: registerJsonXml },
{ id: "merge-csvs", register: registerMergeCsvs },
{ id: "split-csv", register: registerSplitCsv },
{ id: "xml-to-csv", register: registerXmlToCsv },
{ id: "yaml-json", register: registerYamlJson },
// AI Tools
{ id: "remove-background", register: registerRemoveBackground },
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { runPandoc } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerMarkdownToDocx(app: FastifyInstance) {
createToolRoute(app, {
toolId: "markdown-to-docx",
settingsSchema,
process: async () => {
throw new Error("markdown-to-docx is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.docx`);
ctx.report(10, "Converting");
await runPandoc(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.docx`,
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
};
},
});
}
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { runPandoc } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerMarkdownToHtml(app: FastifyInstance) {
createToolRoute(app, {
toolId: "markdown-to-html",
settingsSchema,
process: async () => {
throw new Error("markdown-to-html is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.html`);
ctx.report(10, "Converting");
await runPandoc(inPath, outPath, { extraArgs: ["--standalone"] });
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.html`,
contentType: "text/html",
};
},
});
}
+72
View File
@@ -0,0 +1,72 @@
import type { FastifyInstance } from "fastify";
import Papa from "papaparse";
import { z } from "zod";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerMergeCsvs(app: FastifyInstance) {
createToolRoute(app, {
toolId: "merge-csvs",
maxInputs: 20,
settingsSchema,
process: async () => {
throw new Error("merge-csvs is v2-only");
},
processV2: async (ctx) => {
if (ctx.inputs.length < 2) {
throw new InputValidationError("Merging needs at least two CSV files");
}
// Parse the first file to establish column order and delimiter
const firstText = ctx.inputs[0].buffer.toString("utf8");
const firstResult = Papa.parse<Record<string, unknown>>(firstText, {
header: true,
skipEmptyLines: true,
});
if (firstResult.errors.length > 0) {
throw new InputValidationError(`CSV parse failed: ${firstResult.errors[0].message}`);
}
const fields = firstResult.meta.fields ?? [];
const fieldSet = new Set(fields);
const detectedDelimiter = firstResult.meta.delimiter || ",";
const allRows: Record<string, unknown>[] = [...firstResult.data];
// Parse remaining files and validate headers match
for (let i = 1; i < ctx.inputs.length; i++) {
const input = ctx.inputs[i];
const text = input.buffer.toString("utf8");
const result = Papa.parse<Record<string, unknown>>(text, {
header: true,
skipEmptyLines: true,
});
if (result.errors.length > 0) {
throw new InputValidationError(
`CSV parse failed in ${input.filename}: ${result.errors[0].message}`,
);
}
const otherFields = new Set(result.meta.fields ?? []);
if (otherFields.size !== fieldSet.size || ![...otherFields].every((f) => fieldSet.has(f))) {
throw new InputValidationError(`${input.filename} has different columns`);
}
allRows.push(...result.data);
}
// Unparse preserving the first file's field order and delimiter
const merged = Papa.unparse(allRows, {
columns: fields,
delimiter: detectedDelimiter,
});
return {
buffer: Buffer.from(merged, "utf8"),
filename: "merged.csv",
contentType: "text/csv",
};
},
});
}
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerPowerpointToPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "powerpoint-to-pdf",
settingsSchema,
process: async () => {
throw new Error("powerpoint-to-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
// Sanitize the basename but keep the real extension so LibreOffice
// can sniff the input format (e.g. .pptx vs .ppt vs .odp).
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Converting");
const outPath = await convertDocument(inPath, ctx.scratchDir, "pdf", {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.pdf`,
contentType: "application/pdf",
};
},
});
}
+37
View File
@@ -0,0 +1,37 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { runPandoc } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerToEpub(app: FastifyInstance) {
createToolRoute(app, {
toolId: "to-epub",
settingsSchema,
process: async () => {
throw new Error("to-epub is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.epub`);
ctx.report(10, "Converting");
await runPandoc(inPath, outPath, {
extraArgs: ["--metadata", `title=${base}`],
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.epub`,
contentType: "application/epub+zip",
};
},
});
}
+90
View File
@@ -0,0 +1,90 @@
import { XMLParser } from "fast-xml-parser";
import type { FastifyInstance } from "fastify";
import Papa from "papaparse";
import { z } from "zod";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
const TEN_MIB = 10 * 1024 * 1024;
/**
* Walk the parsed XML tree depth-first and return the first value that
* is an array of objects (the "repeating elements" suitable for tabulation).
*/
function findFirstArray(node: unknown): Record<string, unknown>[] | null {
if (Array.isArray(node)) {
if (node.length > 0 && typeof node[0] === "object" && node[0] !== null) {
return node as Record<string, unknown>[];
}
return null;
}
if (typeof node === "object" && node !== null) {
for (const val of Object.values(node)) {
const found = findFirstArray(val);
if (found) return found;
}
}
return null;
}
/**
* Flatten one level: nested objects and arrays become JSON strings in the cell.
*/
function flattenRow(row: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(row)) {
if (val !== null && typeof val === "object") {
out[key] = JSON.stringify(val);
} else {
out[key] = val;
}
}
return out;
}
export function registerXmlToCsv(app: FastifyInstance) {
createToolRoute(app, {
toolId: "xml-to-csv",
settingsSchema,
process: async () => {
throw new Error("xml-to-csv is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
if (input.buffer.length > TEN_MIB) {
throw new InputValidationError("File too large for conversion (10 MB limit)");
}
const text = input.buffer.toString("utf8");
// Mirror json-xml.ts parser options
const parser = new XMLParser({ ignoreAttributes: false });
let parsed: unknown;
try {
parsed = parser.parse(text);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new InputValidationError(`XML parse failed: ${msg.split("\n")[0]}`);
}
const rows = findFirstArray(parsed);
if (!rows || rows.length === 0) {
throw new InputValidationError("No repeating elements found to tabulate");
}
const flattened = rows.map(flattenRow);
const csv = Papa.unparse(flattened);
return {
buffer: Buffer.from(csv, "utf8"),
filename: `${base}.csv`,
contentType: "text/csv",
resultPayload: { rows: flattened.length },
};
},
});
}
+62
View File
@@ -0,0 +1,62 @@
import type { FastifyInstance } from "fastify";
import jsYaml from "js-yaml";
import { z } from "zod";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
const TEN_MIB = 10 * 1024 * 1024;
export function registerYamlJson(app: FastifyInstance) {
createToolRoute(app, {
toolId: "yaml-json",
settingsSchema,
process: async () => {
throw new Error("yaml-json is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const lower = input.filename.toLowerCase();
if (input.buffer.length > TEN_MIB) {
throw new InputValidationError("File too large for conversion (10 MB limit)");
}
const text = input.buffer.toString("utf8");
if (lower.endsWith(".json")) {
// JSON -> YAML
let data: unknown;
try {
data = JSON.parse(text);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new InputValidationError(`Not valid JSON: ${msg.split("\n")[0]}`);
}
const yaml = jsYaml.dump(data);
return {
buffer: Buffer.from(yaml, "utf8"),
filename: `${base}.yaml`,
contentType: "text/yaml",
};
}
// YAML -> JSON (handles .yaml and .yml)
let parsed: unknown;
try {
parsed = jsYaml.load(text);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new InputValidationError(`Not valid YAML: ${msg.split("\n")[0]}`);
}
const json = JSON.stringify(parsed, null, 2);
return {
buffer: Buffer.from(json, "utf8"),
filename: `${base}.json`,
contentType: "application/json",
};
},
});
}