diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..aa090b9
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+.git
+.podman
+artifacts
+.container-home
+db.sqlite3
+docker/data
+signal-cli-config
+__pycache__
+*.pyc
diff --git a/.gitignore b/.gitignore
index 7f355a2..31c1f2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -166,3 +166,4 @@ oom
node_modules/
.podman/
.beads/
+.sisyphus/
diff --git a/Makefile b/Makefile
index 30f0568..4f7ae52 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@ run:
bash $(QUADLET_MGR) up
build:
- docker-compose --env-file=stack.env build
+ OPERATION=uwsgi podman build --build-arg OPERATION=uwsgi -t localhost/xf/gia:prod -f Dockerfile .
stop:
bash $(QUADLET_MGR) down
diff --git a/app/urls.py b/app/urls.py
index f2b733e..da364fb 100644
--- a/app/urls.py
+++ b/app/urls.py
@@ -220,6 +220,16 @@ urlpatterns = [
compose.ComposeContactMatch.as_view(),
name="compose_contact_match",
),
+ path(
+ "compose/contacts/create/",
+ compose.ComposeContactCreate.as_view(),
+ name="compose_contact_create",
+ ),
+ path(
+ "compose/contacts/create-all/",
+ compose.ComposeContactCreateAll.as_view(),
+ name="compose_contact_create_all",
+ ),
# AIs
path(
"ai/workspace/",
diff --git a/core/templates/pages/compose-contact-match.html b/core/templates/pages/compose-contact-match.html
index b667510..9192032 100644
--- a/core/templates/pages/compose-contact-match.html
+++ b/core/templates/pages/compose-contact-match.html
@@ -13,7 +13,18 @@
-
+
+
Manual Workspace
@@ -187,23 +198,43 @@
{{ row.identifier }} |
- {% if not row.linked_person and row.suggestions %}
+ {% if not row.linked_person %}
- {% for suggestion in row.suggestions %}
-
- {% endfor %}
+ {% endif %}
+ {% if row.suggestions %}
+ {% for suggestion in row.suggestions %}
+
+ {% endfor %}
+ {% endif %}
{% else %}
-
@@ -436,6 +467,41 @@
if (ev.key === "Escape") hidePopover();
});
+ document.querySelectorAll(".js-create-contact").forEach((btn) => {
+ btn.addEventListener("click", function (ev) {
+ const name = this.dataset.name || "this contact";
+ const identifier = this.dataset.identifier || "";
+ const service = this.dataset.service || "";
+ const confirmMsg = "Create new person '" + name + "' and link to " + identifier + " (" + service + ")?";
+ if (!confirm(confirmMsg)) {
+ ev.preventDefault();
+ return false;
+ }
+ });
+ });
+
+ const createAllBtn = document.getElementById("create-all-btn");
+ if (createAllBtn) {
+ createAllBtn.addEventListener("click", function (ev) {
+ const table = document.getElementById("discovered-contacts-table");
+ if (!table) return;
+ const unlinkedWithName = table.querySelectorAll("tbody tr").filter((row) => {
+ const statusCell = row.querySelector('[data-discovered-col="5"]');
+ return statusCell && statusCell.textContent.includes("unlinked");
+ }).length;
+ if (unlinkedWithName === 0) {
+ ev.preventDefault();
+ alert("No unlinked contacts with detected names to create.");
+ return false;
+ }
+ const confirmMsg = "Create " + unlinkedWithName + " new contact" + (unlinkedWithName > 1 ? "s" : "") + " from detected names?";
+ if (!confirm(confirmMsg)) {
+ ev.preventDefault();
+ return false;
+ }
+ });
+ }
+
applyFilters();
applyColumns();
})();
diff --git a/core/views/compose.py b/core/views/compose.py
index 30855d9..4615689 100644
--- a/core/views/compose.py
+++ b/core/views/compose.py
@@ -2361,6 +2361,131 @@ class ComposeContactMatch(LoginRequiredMixin, View):
)
+class ComposeContactCreate(LoginRequiredMixin, View):
+ template_name = "pages/compose-contact-match.html"
+
+ def post(self, request):
+ service = _default_service(request.POST.get("service"))
+ identifier = str(request.POST.get("identifier") or "").strip()
+ person_name = str(request.POST.get("person_name") or "").strip()
+
+ if not identifier:
+ return render(
+ request,
+ self.template_name,
+ self._context(request, "Identifier is required.", "warning"),
+ )
+
+ if not person_name:
+ return render(
+ request,
+ self.template_name,
+ self._context(request, "Person name is required.", "warning"),
+ )
+
+ existing = PersonIdentifier.objects.filter(
+ user=request.user,
+ service=service,
+ identifier=identifier,
+ ).first()
+
+ if existing and existing.person:
+ return render(
+ request,
+ self.template_name,
+ self._context(
+ request,
+ f"{identifier} ({service}) is already linked to {existing.person.name}.",
+ "warning",
+ ),
+ )
+
+ person = Person.objects.create(user=request.user, name=person_name)
+
+ PersonIdentifier.objects.create(
+ user=request.user,
+ person=person,
+ service=service,
+ identifier=identifier,
+ )
+
+ message = f"Created person '{person_name}' and linked {identifier} ({service})."
+ return render(
+ request,
+ self.template_name,
+ self._context(request, message, "success"),
+ )
+
+
+class ComposeContactCreateAll(LoginRequiredMixin, View):
+ template_name = "pages/compose-contact-match.html"
+
+ def post(self, request):
+ candidates = _manual_contact_rows(request.user)
+
+ created_count = 0
+ skipped_count = 0
+ errors = []
+
+ for candidate in candidates:
+ if candidate.get("linked_person"):
+ skipped_count += 1
+ continue
+
+ detected_name = candidate.get("detected_name", "")
+ if not detected_name:
+ skipped_count += 1
+ continue
+
+ service = candidate.get("service", "")
+ identifier = candidate.get("identifier", "")
+
+ if not service or not identifier:
+ skipped_count += 1
+ continue
+
+ existing = PersonIdentifier.objects.filter(
+ user=request.user,
+ service=service,
+ identifier=identifier,
+ ).first()
+
+ if existing and existing.person:
+ skipped_count += 1
+ continue
+
+ try:
+ person = Person.objects.create(user=request.user, name=detected_name)
+
+ PersonIdentifier.objects.create(
+ user=request.user,
+ person=person,
+ service=service,
+ identifier=identifier,
+ )
+
+ created_count += 1
+ except Exception as e:
+ errors.append(f"{identifier} ({service}): {str(e)}")
+ skipped_count += 1
+
+ if errors:
+ message = f"Created {created_count} contacts. Errors: {'; '.join(errors[:5])}"
+ level = "warning"
+ elif created_count > 0:
+ message = f"Created {created_count} new contact{'s' if created_count != 1 else ''}. Skipped {skipped_count}."
+ level = "success"
+ else:
+ message = f"No new contacts to create. {skipped_count} already linked or without names."
+ level = "info"
+
+ return render(
+ request,
+ self.template_name,
+ self._context(request, message, level),
+ )
+
+
class ComposePage(LoginRequiredMixin, View):
template_name = "pages/compose.html"
diff --git a/scripts/quadlet/manage.sh b/scripts/quadlet/manage.sh
index 714dbde..6ebf128 100755
--- a/scripts/quadlet/manage.sh
+++ b/scripts/quadlet/manage.sh
@@ -35,7 +35,11 @@ ensure_dirs() {
mkdir -p "$REDIS_DATA_DIR" "$WHATSAPP_DATA_DIR" "$VRUN_DIR" "$ROOT_DIR/signal-cli-config"
# Container runs as uid 1000 (xf); rootless Podman remaps uids so plain
# chown won't work — podman unshare translates to the correct host uid.
- podman unshare chown 1000:1000 "$WHATSAPP_DATA_DIR" 2>/dev/null || true
+ if [[ -n "${QUADLET_SKIP_UNSHARE:-}" ]] || [[ -n "${CONTAINER_HOST:-}" ]] || [[ -n "${PODMAN_HOST:-}" ]] || [[ -n "${DOCKER_HOST:-}" ]]; then
+ echo "Skipping podman unshare chown (QUADLET_SKIP_UNSHARE or remote host detected)"
+ else
+ podman unshare chown 1000:1000 "$WHATSAPP_DATA_DIR" 2>/dev/null || true
+ fi
}
rm_if_exists() {
|