propongo 1.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +1 -0
- app/config.py +43 -0
- app/export.py +484 -0
- app/main.py +1041 -0
- app/models.py +201 -0
- app/snippets/deliverables.json +32 -0
- app/snippets/organization.json +26 -0
- app/snippets.py +156 -0
- app/static/css/style.css +1359 -0
- app/static/js/app.js +712 -0
- app/static/js/budget.js +352 -0
- app/static/js/gantt.js +519 -0
- app/static/js/snippets.js +181 -0
- app/static/js/tracker.js +152 -0
- app/templates/base.html +171 -0
- app/templates/budget.html +113 -0
- app/templates/custom_sections.html +105 -0
- app/templates/export_proposal.html +354 -0
- app/templates/export_tracker.html +254 -0
- app/templates/index.html +53 -0
- app/templates/preview.html +178 -0
- app/templates/qualifications.html +17 -0
- app/templates/scope.html +85 -0
- app/templates/templates.html +56 -0
- app/templates/timeline.html +190 -0
- app/templates/tracker.html +226 -0
- app/utils.py +257 -0
- propongo-1.3.4.dist-info/METADATA +240 -0
- propongo-1.3.4.dist-info/RECORD +32 -0
- propongo-1.3.4.dist-info/WHEEL +5 -0
- propongo-1.3.4.dist-info/entry_points.txt +2 -0
- propongo-1.3.4.dist-info/top_level.txt +1 -0
app/models.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Data models for Propongo.
|
|
2
|
+
|
|
3
|
+
Tasks and budget items are stored as dictionaries with the following structure:
|
|
4
|
+
|
|
5
|
+
Task dict:
|
|
6
|
+
{
|
|
7
|
+
'id': str, # UUID
|
|
8
|
+
'name': str, # Task name
|
|
9
|
+
'description': str, # Task description
|
|
10
|
+
'lead_entity': str, # Organization responsible
|
|
11
|
+
'start_month': int, # Start month (1-12)
|
|
12
|
+
'start_year': int, # Start year
|
|
13
|
+
'duration_months': int, # Duration in months
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
BudgetItem dict:
|
|
17
|
+
{
|
|
18
|
+
'id': str, # UUID
|
|
19
|
+
'task_id': str, # Associated task UUID
|
|
20
|
+
'name': str, # Item name
|
|
21
|
+
'cost_per_unit': float, # Unit cost
|
|
22
|
+
'units': float, # Number of units
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Custom Section dict:
|
|
26
|
+
{
|
|
27
|
+
'id': str, # UUID
|
|
28
|
+
'title': str, # Section title
|
|
29
|
+
'content': str, # Markdown content
|
|
30
|
+
'order': int, # Display order
|
|
31
|
+
}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
import shutil
|
|
38
|
+
import sys
|
|
39
|
+
from dataclasses import dataclass, field, asdict
|
|
40
|
+
from typing import Optional
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
import uuid
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
if sys.platform == "win32":
|
|
47
|
+
_DATA_ROOT = os.path.join(os.path.expanduser("~"), "Documents", "Propongo")
|
|
48
|
+
else:
|
|
49
|
+
_DATA_ROOT = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
|
|
50
|
+
PROPOSALS_DIR = os.path.join(_DATA_ROOT, "proposals")
|
|
51
|
+
TEMPLATES_DIR = os.path.join(_DATA_ROOT, "templates")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _migrate_from_propongo2() -> None:
|
|
55
|
+
"""Migrate user data from Propongo2 to Propongo on Windows."""
|
|
56
|
+
if sys.platform != "win32":
|
|
57
|
+
return
|
|
58
|
+
old_root = os.path.join(os.path.expanduser("~"), "Documents", "Propongo2")
|
|
59
|
+
if not os.path.isdir(old_root):
|
|
60
|
+
return
|
|
61
|
+
if os.path.isdir(_DATA_ROOT):
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
shutil.copytree(old_root, _DATA_ROOT)
|
|
65
|
+
logger.info("Migrated data from %s to %s", old_root, _DATA_ROOT)
|
|
66
|
+
except OSError:
|
|
67
|
+
logger.warning("Could not migrate data from %s", old_root)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def ensure_dirs() -> None:
|
|
71
|
+
"""Ensure data directories exist."""
|
|
72
|
+
_migrate_from_propongo2()
|
|
73
|
+
os.makedirs(PROPOSALS_DIR, exist_ok=True)
|
|
74
|
+
os.makedirs(TEMPLATES_DIR, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Proposal:
|
|
79
|
+
"""A project proposal containing tasks, budget items, and custom sections."""
|
|
80
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
81
|
+
title: str = "Untitled Proposal"
|
|
82
|
+
client_name: str = ""
|
|
83
|
+
subtitle: str = ""
|
|
84
|
+
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
85
|
+
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
86
|
+
|
|
87
|
+
project_summary: str = ""
|
|
88
|
+
scope: str = ""
|
|
89
|
+
tasks: list = field(default_factory=list)
|
|
90
|
+
qualifications: str = ""
|
|
91
|
+
|
|
92
|
+
budget_items: list = field(default_factory=list)
|
|
93
|
+
budget_item_timings: dict = field(default_factory=dict)
|
|
94
|
+
start_date: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d"))
|
|
95
|
+
end_date: str = ""
|
|
96
|
+
indirect_percent: float = 0.0
|
|
97
|
+
show_budget_description: bool = False
|
|
98
|
+
budget_description: str = ""
|
|
99
|
+
timeline_use_days: bool = False
|
|
100
|
+
timeline_show_budget: bool = False
|
|
101
|
+
custom_sections: list = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
is_template: bool = False
|
|
104
|
+
template_name: str = ""
|
|
105
|
+
template_category: str = ""
|
|
106
|
+
|
|
107
|
+
milestones: list = field(default_factory=list)
|
|
108
|
+
reports: list = field(default_factory=list)
|
|
109
|
+
|
|
110
|
+
def to_dict(self) -> dict:
|
|
111
|
+
"""Serialize the proposal to a dictionary."""
|
|
112
|
+
return asdict(self)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_dict(cls, data: dict) -> "Proposal":
|
|
116
|
+
"""Create a Proposal from a dictionary, ignoring unknown fields."""
|
|
117
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
118
|
+
|
|
119
|
+
def save(self):
|
|
120
|
+
"""Save the proposal to disk as a JSON file."""
|
|
121
|
+
ensure_dirs()
|
|
122
|
+
self.updated_at = datetime.now().isoformat()
|
|
123
|
+
target_dir = TEMPLATES_DIR if self.is_template else PROPOSALS_DIR
|
|
124
|
+
filepath = os.path.join(target_dir, f"{self.id}.json")
|
|
125
|
+
tmp = filepath + ".tmp"
|
|
126
|
+
with open(tmp, "w") as f:
|
|
127
|
+
json.dump(self.to_dict(), f, indent=2)
|
|
128
|
+
f.write("\n")
|
|
129
|
+
os.replace(tmp, filepath)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def load(cls, proposal_id: str, is_template: bool = False) -> Optional["Proposal"]:
|
|
133
|
+
"""Load a proposal or template by ID from disk."""
|
|
134
|
+
target_dir = TEMPLATES_DIR if is_template else PROPOSALS_DIR
|
|
135
|
+
filepath = os.path.join(target_dir, f"{proposal_id}.json")
|
|
136
|
+
if not os.path.exists(filepath):
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
with open(filepath, "r") as f:
|
|
140
|
+
return cls.from_dict(json.load(f))
|
|
141
|
+
except (json.JSONDecodeError, OSError):
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def list_all(cls) -> list:
|
|
146
|
+
"""List all proposals, returning summaries sorted by most recent."""
|
|
147
|
+
ensure_dirs()
|
|
148
|
+
proposals = []
|
|
149
|
+
for filename in sorted(os.listdir(PROPOSALS_DIR)):
|
|
150
|
+
if filename.endswith(".json"):
|
|
151
|
+
try:
|
|
152
|
+
with open(os.path.join(PROPOSALS_DIR, filename), "r") as f:
|
|
153
|
+
data = json.load(f)
|
|
154
|
+
proposals.append({
|
|
155
|
+
"id": data.get("id", filename.replace(".json", "")),
|
|
156
|
+
"title": data.get("title", "Untitled"),
|
|
157
|
+
"client_name": data.get("client_name", ""),
|
|
158
|
+
"updated_at": data.get("updated_at", ""),
|
|
159
|
+
})
|
|
160
|
+
except (json.JSONDecodeError, OSError):
|
|
161
|
+
continue
|
|
162
|
+
return sorted(proposals, key=lambda x: x["updated_at"], reverse=True)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def list_templates(cls) -> list:
|
|
166
|
+
"""List all templates, returning summaries sorted by most recent."""
|
|
167
|
+
ensure_dirs()
|
|
168
|
+
templates = []
|
|
169
|
+
for filename in sorted(os.listdir(TEMPLATES_DIR)):
|
|
170
|
+
if filename.endswith(".json"):
|
|
171
|
+
try:
|
|
172
|
+
with open(os.path.join(TEMPLATES_DIR, filename), "r") as f:
|
|
173
|
+
data = json.load(f)
|
|
174
|
+
templates.append({
|
|
175
|
+
"id": data.get("id", filename.replace(".json", "")),
|
|
176
|
+
"title": data.get("title", "Untitled"),
|
|
177
|
+
"template_name": data.get("template_name", ""),
|
|
178
|
+
"template_category": data.get("template_category", ""),
|
|
179
|
+
"updated_at": data.get("updated_at", ""),
|
|
180
|
+
})
|
|
181
|
+
except (json.JSONDecodeError, OSError):
|
|
182
|
+
continue
|
|
183
|
+
return sorted(templates, key=lambda x: x["updated_at"], reverse=True)
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def delete(cls, proposal_id: str, is_template: bool = False) -> bool:
|
|
187
|
+
"""Delete a proposal or template by ID. Returns True if deleted."""
|
|
188
|
+
target_dir = TEMPLATES_DIR if is_template else PROPOSALS_DIR
|
|
189
|
+
filepath = os.path.join(target_dir, f"{proposal_id}.json")
|
|
190
|
+
if os.path.exists(filepath):
|
|
191
|
+
os.remove(filepath)
|
|
192
|
+
return True
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def total_budget(self) -> float:
|
|
197
|
+
"""Compute total budget as the sum of cost_per_unit * units for all items."""
|
|
198
|
+
return sum(
|
|
199
|
+
item.get("cost_per_unit", 0) * item.get("units", 0)
|
|
200
|
+
for item in self.budget_items
|
|
201
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "del-survey",
|
|
4
|
+
"title": "Field Survey Report",
|
|
5
|
+
"content": "## Field Survey Report\n\nA comprehensive field survey will be conducted to assess current conditions, identify key features, and document existing resources. The survey will include:\n\n- Site walkthrough and observation\n- Photo documentation of key areas\n- GPS mapping of boundaries and features\n- Data collection using standardized protocols\n- Analysis and written report with recommendations\n\n**Deliverable:** Written report with maps, photographs, and management recommendations.",
|
|
6
|
+
"category": "deliverables"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "del-assessment",
|
|
10
|
+
"title": "Natural Resource Assessment",
|
|
11
|
+
"content": "## Natural Resource Assessment\n\nA detailed assessment of natural resources within the project area, including:\n\n- Inventory of plant and animal species\n- Habitat condition evaluation\n- Water quality and hydrology review\n- Soil analysis and erosion risk mapping\n- Comparison with regional benchmarks\n\n**Deliverable:** Technical report with data tables, maps, and condition ratings.",
|
|
12
|
+
"category": "deliverables"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "del-management-plan",
|
|
16
|
+
"title": "Management Plan",
|
|
17
|
+
"content": "## Conservation Management Plan\n\nA comprehensive management plan outlining strategies and actions for long-term resource stewardship:\n\n- Current conditions summary\n- Goals and objectives\n- Recommended management actions\n- Implementation schedule\n- Monitoring and evaluation framework\n- Budget estimates for implementation\n\n**Deliverable:** Written plan with appendices, maps, and implementation timeline.",
|
|
18
|
+
"category": "deliverables"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "del-monitoring",
|
|
22
|
+
"title": "Monitoring Protocol",
|
|
23
|
+
"content": "## Monitoring Protocol\n\nEstablishment of a monitoring program to track conservation outcomes:\n\n- Key performance indicators (KPIs)\n- Sampling design and methods\n- Data collection schedules\n- Reporting templates\n- Adaptive management triggers\n\n**Deliverable:** Monitoring protocol document with data sheets and reporting schedule.",
|
|
24
|
+
"category": "deliverables"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "del-gis",
|
|
28
|
+
"title": "GIS Mapping Package",
|
|
29
|
+
"content": "## GIS Mapping Package\n\nDigital mapping products to support project planning and implementation:\n\n- Base maps with aerial imagery\n- Land cover/land use classification\n- Habitat type mapping\n- Topographic analysis\n- Project area boundary delineation\n\n**Deliverable:** GIS geodatabase, PDF maps, and web map viewer.",
|
|
30
|
+
"category": "deliverables"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "org-about",
|
|
4
|
+
"title": "About Our Organization",
|
|
5
|
+
"content": "VR Conservation is dedicated to the preservation and restoration of natural resources through science-based conservation practices. Our team combines decades of field experience with cutting-edge technology to deliver measurable conservation outcomes for communities, landowners, and government agencies.",
|
|
6
|
+
"category": "organization"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "org-mission",
|
|
10
|
+
"title": "Mission Statement",
|
|
11
|
+
"content": "Our mission is to protect and restore critical ecosystems through collaborative conservation, research, and community engagement. We work alongside stakeholders to develop sustainable solutions that balance ecological integrity with community needs.",
|
|
12
|
+
"category": "organization"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "org-experience",
|
|
16
|
+
"title": "Relevant Experience",
|
|
17
|
+
"content": "Our team has successfully completed over 50 conservation projects across the region, including watershed restoration, wildlife habitat improvement, and natural resource assessments. We maintain strong relationships with state and federal agencies, ensuring our work meets or exceeds regulatory standards.",
|
|
18
|
+
"category": "organization"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "org-team",
|
|
22
|
+
"title": "Team Qualifications",
|
|
23
|
+
"content": "Our interdisciplinary team includes certified wildlife biologists, hydrologists, GIS specialists, and project managers. Each team member holds relevant certifications and brings a minimum of 10 years of field experience to every project.",
|
|
24
|
+
"category": "organization"
|
|
25
|
+
}
|
|
26
|
+
]
|
app/snippets.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Snippet management for reusable text blocks."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import uuid
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Tuple, Dict, List, Any
|
|
9
|
+
from flask import Blueprint, render_template, request, jsonify, Response
|
|
10
|
+
from .config import ERROR_MESSAGES
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
snippets_bp = Blueprint("snippets", __name__)
|
|
15
|
+
|
|
16
|
+
_PKG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "snippets")
|
|
17
|
+
if sys.platform == "win32":
|
|
18
|
+
SNIPPETS_DIR = os.path.join(os.path.expanduser("~"), "Documents", "Propongo", "snippets")
|
|
19
|
+
else:
|
|
20
|
+
SNIPPETS_DIR = _PKG_DIR
|
|
21
|
+
CUSTOM_DIR = os.path.join(SNIPPETS_DIR, "custom")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ensure_dirs():
|
|
25
|
+
"""Ensure snippet directories exist."""
|
|
26
|
+
os.makedirs(SNIPPETS_DIR, exist_ok=True)
|
|
27
|
+
os.makedirs(CUSTOM_DIR, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_snippets(filename):
|
|
31
|
+
"""Load stock snippets from the package directory."""
|
|
32
|
+
filepath = os.path.join(_PKG_DIR, filename)
|
|
33
|
+
if os.path.exists(filepath):
|
|
34
|
+
with open(filepath, "r") as f:
|
|
35
|
+
return json.load(f)
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_snippets(filename, data):
|
|
40
|
+
"""Save snippets to a JSON file."""
|
|
41
|
+
ensure_dirs()
|
|
42
|
+
filepath = os.path.join(SNIPPETS_DIR, filename)
|
|
43
|
+
with open(filepath, "w") as f:
|
|
44
|
+
json.dump(data, f, indent=2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_custom_snippets():
|
|
48
|
+
"""Load all user-created custom snippets."""
|
|
49
|
+
ensure_dirs()
|
|
50
|
+
snippets = []
|
|
51
|
+
for filename in sorted(os.listdir(CUSTOM_DIR)):
|
|
52
|
+
if filename.endswith(".json"):
|
|
53
|
+
with open(os.path.join(CUSTOM_DIR, filename), "r") as f:
|
|
54
|
+
snippets.append(json.load(f))
|
|
55
|
+
return snippets
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@snippets_bp.route("/snippets")
|
|
59
|
+
def get_all_snippets():
|
|
60
|
+
"""Return all snippets grouped by category."""
|
|
61
|
+
return jsonify({
|
|
62
|
+
"organization": load_snippets("organization.json"),
|
|
63
|
+
"deliverables": load_snippets("deliverables.json"),
|
|
64
|
+
"custom": load_custom_snippets(),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@snippets_bp.route("/snippets/<category>", methods=["POST"])
|
|
69
|
+
def add_snippet(category):
|
|
70
|
+
"""Add a new snippet to the given category."""
|
|
71
|
+
data = request.get_json()
|
|
72
|
+
if not data or "title" not in data or "content" not in data:
|
|
73
|
+
return jsonify({"error": "title and content required"}), 400
|
|
74
|
+
|
|
75
|
+
snippet = {
|
|
76
|
+
"id": data.get("id", uuid.uuid4().hex[:8]),
|
|
77
|
+
"title": data["title"],
|
|
78
|
+
"content": data["content"],
|
|
79
|
+
"category": category,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if category == "custom":
|
|
83
|
+
ensure_dirs()
|
|
84
|
+
filepath = os.path.join(CUSTOM_DIR, f"{snippet['id']}.json")
|
|
85
|
+
with open(filepath, "w") as f:
|
|
86
|
+
json.dump(snippet, f, indent=2)
|
|
87
|
+
elif category in ("organization", "deliverables"):
|
|
88
|
+
snippets = load_snippets(f"{category}.json")
|
|
89
|
+
snippets.append(snippet)
|
|
90
|
+
save_snippets(f"{category}.json", snippets)
|
|
91
|
+
else:
|
|
92
|
+
return jsonify({"error": "Invalid category"}), 400
|
|
93
|
+
|
|
94
|
+
return jsonify(snippet), 201
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@snippets_bp.route("/snippets/<category>/<snippet_id>", methods=["DELETE"])
|
|
98
|
+
def delete_snippet(category, snippet_id):
|
|
99
|
+
"""Delete a snippet by category and ID."""
|
|
100
|
+
if category == "custom":
|
|
101
|
+
filepath = os.path.join(CUSTOM_DIR, f"{snippet_id}.json")
|
|
102
|
+
if os.path.exists(filepath):
|
|
103
|
+
os.remove(filepath)
|
|
104
|
+
return jsonify({"ok": True})
|
|
105
|
+
elif category in ("organization", "deliverables"):
|
|
106
|
+
snippets = load_snippets(f"{category}.json")
|
|
107
|
+
snippets = [s for s in snippets if s.get("id") != snippet_id]
|
|
108
|
+
save_snippets(f"{category}.json", snippets)
|
|
109
|
+
return jsonify({"ok": True})
|
|
110
|
+
|
|
111
|
+
return jsonify(ERROR_MESSAGES['SECTION_NOT_FOUND']), 404
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@snippets_bp.route("/snippets/import", methods=["POST"])
|
|
115
|
+
def import_snippet():
|
|
116
|
+
"""Import a snippet from a .md, .txt, or .docx file."""
|
|
117
|
+
if "file" not in request.files:
|
|
118
|
+
return jsonify(ERROR_MESSAGES['NO_FILE']), 400
|
|
119
|
+
|
|
120
|
+
file = request.files["file"]
|
|
121
|
+
if not file.filename:
|
|
122
|
+
return jsonify(ERROR_MESSAGES['NO_FILE']), 400
|
|
123
|
+
|
|
124
|
+
filename = file.filename.lower()
|
|
125
|
+
title = request.form.get("title", "").strip()
|
|
126
|
+
if not title:
|
|
127
|
+
title = os.path.splitext(file.filename)[0]
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
if filename.endswith(".md") or filename.endswith(".markdown") or filename.endswith(".txt"):
|
|
131
|
+
content = file.read().decode("utf-8")
|
|
132
|
+
elif filename.endswith(".docx"):
|
|
133
|
+
from docx import Document
|
|
134
|
+
doc = Document(file)
|
|
135
|
+
content = "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
|
136
|
+
else:
|
|
137
|
+
return jsonify({"error": "Unsupported file type. Use .md, .txt, or .docx"}), 400
|
|
138
|
+
except Exception as e:
|
|
139
|
+
return jsonify({"error": f"Failed to read file: {str(e)}"}), 400
|
|
140
|
+
|
|
141
|
+
if not content.strip():
|
|
142
|
+
return jsonify({"error": "File is empty"}), 400
|
|
143
|
+
|
|
144
|
+
snippet = {
|
|
145
|
+
"id": uuid.uuid4().hex[:8],
|
|
146
|
+
"title": title,
|
|
147
|
+
"content": content,
|
|
148
|
+
"category": "custom",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
ensure_dirs()
|
|
152
|
+
filepath = os.path.join(CUSTOM_DIR, f"{snippet['id']}.json")
|
|
153
|
+
with open(filepath, "w") as f:
|
|
154
|
+
json.dump(snippet, f, indent=2)
|
|
155
|
+
|
|
156
|
+
return jsonify(snippet), 201
|