API Reference
Complete reference for Loopar's server-side APIs.
Core Objects
| Object | Description |
|---|---|
loopar | Global framework object |
BaseDocument | Base class for Models |
BaseController | Base class for Controllers |
Import Patterns
loopar is a named export on the server:
// In Model (.js)
import { BaseDocument, loopar } from "loopar";
// In Controller (-controller.js)
import { BaseController, loopar } from "loopar";
// In any server file
import { loopar } from "loopar";
// With filter operators
import { loopar, Op } from "loopar";
On the client (
.jsxfiles)looparresolves to the browser runtime, which also exposeslooparas a named export:import { loopar } from "loopar";
Quick Reference
Document Operations
const doc = await loopar.getDocument("Entity", "name");
const doc = await loopar.newDocument("Entity");
await doc.save();
await doc.delete();
const list = await loopar.getList("Entity", { filters: { status: "Active" } });
Database Queries
const all = await loopar.db.getAll("Entity", fields, filter);
const count = await loopar.db.count("Entity", filters);
const value = await loopar.db.getValue("Entity", "name", "field");
Utilities
const user = loopar.currentUser; // logged-in user (or null)
const settings = await loopar.getSettings();
await loopar.mail.send({ to, subject, html });
loopar (Global Object)
The main framework object available throughout the server-side code.
import { loopar } from "loopar";
Document Methods
loopar.getDocument(entity, name, data?, options?)
Retrieve an existing document by name.
const customer = await loopar.getDocument("Customer", "CUST-0001");
// Access fields
console.log(customer.email);
console.log(customer.status);
// Call model methods
const fullName = customer.getFullName();
// Return null instead of throwing when missing
const maybe = await loopar.getDocument("Customer", "CUST-0001", null, { ifNotFound: null });
| Parameter | Type | Description |
|---|---|---|
entity | string | Entity name |
name | string | Document name/ID |
data | object | Values to merge onto the loaded document (optional) |
options | object | { ifNotFound: "throw" | null } — default throws a 404 |
Returns: Document instance
loopar.newDocument(entity, data?)
Create a new document instance.
// Create empty
const customer = await loopar.newDocument("Customer");
customer.customer_name = "John Doe";
customer.email = "john@example.com";
await customer.save();
// Create with initial data
const task = await loopar.newDocument("Task", {
title: "New Task",
status: "Open",
priority: "High"
});
await task.save();
| Parameter | Type | Description |
|---|---|---|
entity | string | Entity name |
data | object | Initial field values (optional) |
Returns: New document instance (unsaved)
loopar.deleteDocument(entity, name, options?)
Delete a document by name.
await loopar.deleteDocument("Customer", "CUST-0001");
// Hard-delete even if other documents reference it
await loopar.deleteDocument("Customer", "CUST-0001", { force: true, sofDelete: false });
| Option | Default | Description |
|---|---|---|
sofDelete | true | Soft delete (mark as deleted) instead of removing the row |
force | false | Skip the connected-documents guard |
ifNotFound | null | What to do when the document doesn't exist |
loopar.getList(entity, options?)
Paginated list of documents (what the desk list views use).
const list = await loopar.getList("Task", {
fields: ["name", "title", "status"],
filters: { status: "Open" },
q: { title: "urgent" }, // per-field search terms
rowsOnly: false
});
// list.rows, list.pagination { page, pageSize, totalRecords, totalPages }
User & Session
loopar.currentUser
The authenticated user for the current request (or null).
const user = loopar.currentUser;
if (user) {
console.log(user.name, user.email, user.user_type);
}
loopar.auth
Auth helpers live on loopar.auth, not on loopar directly:
const user = await loopar.auth.getUser("user@example.com");
const isDisabled = await loopar.auth.disabledUser(userId);
loopar.session
A per-request key/value store (used e.g. for list filters and pagination) — not a user object.
loopar.session.get("Customerpage"); // current list page for Customer
loopar.session.set("myKey", value);
loopar.session.remove("myKey");
Configuration
loopar.getSettings()
Access system settings (cached after the first call).
// System settings document
const settings = await loopar.getSettings();
// Database connection config (sites/<tenant>/config/db.config.json)
const dbConfig = loopar.getDbConfig();
Utilities
loopar.mail.send(options)
Send an email through the built-in email service (loopar.mail).
await loopar.mail.send({
to: "customer@example.com",
subject: "Welcome!",
html: "<h1>Welcome to our platform</h1>",
// Optional
from: "noreply@yoursite.com",
cc: ["manager@yoursite.com"],
attachments: [{ filename: "doc.pdf", path: "/path/to/doc.pdf" }]
});
| Option | Type | Description |
|---|---|---|
to | string/array | Recipient(s) |
subject | string | Email subject |
html | string | HTML body |
from | string | Sender (optional) |
cc | array | CC recipients |
bcc | array | BCC recipients |
attachments | array | File attachments |
loopar.throw(error, redirect?)
Abort the current action with an error. Pass a string for a generic 400, or an object with code to control the HTTP status. The optional second argument is a redirect URL (used for auth/session failures), not a status code.
if (!customer) {
loopar.throw({ code: 404, message: "Customer not found" });
}
if (!hasPermission) {
loopar.throw({ code: 403, message: "Access denied" });
}
// String → HTTP 400
loopar.throw("Invalid data");
// With redirect (forces the client back to login)
loopar.throw("Session expired", "/auth/login");
loopar.hook(entity, event, callback)
Subscribe to persistence events for an entity — fired at the ORM layer for any write (including loopar.db.setValue-style updates from the document layer).
loopar.hook("Customer", "afterCreate", async (data) => {
await loopar.mail.send({ to: data.email, subject: "Welcome!", html: "..." });
});
Valid events: beforeSave, beforeCreate, beforeUpdate, beforeDelete, afterSave, afterCreate, afterUpdate, afterDelete (beforeSave/afterSave are shorthand for create + update).
loopar.emit(event, payload?)
Broadcast a realtime event to connected clients (Socket.IO, tenant-scoped).
loopar.emit("orders:created", { name: order.name });
loopar.db (Database API)
loopar.db is the framework's data layer — a Knex-powered query engine that works across MySQL/MariaDB, PostgreSQL, SQLite, SQL Server and Oracle.
import { loopar } from "loopar";
const results = await loopar.db.getAll("Customer");
Query Methods
loopar.db.getList(document, fields?, filter?, options?)
Get a paginated list of records.
// All fields, current page
const page = await loopar.db.getList("Customer");
// Selected fields + filter
const active = await loopar.db.getList(
"Customer",
["name", "email", "status"],
{ status: "Active" }
);
Pagination is controlled by loopar.db.pagination and loopar.db.setPage(n).
loopar.db.getAll(document, fields?, filter?)
Same as getList, but returns every matching row (no pagination).
const customers = await loopar.db.getAll(
"Customer",
["name", "email"],
{ status: "Active" }
);
| Parameter | Type | Description |
|---|---|---|
document | string | Entity name |
fields | array | Fields to return (default ["*"]) |
filter | object | WHERE conditions |
loopar.db.getRow(document, filter)
Get a single raw row matching a filter, or null.
const row = await loopar.db.getRow("Customer", { email: "john@example.com" });
loopar.db.getValue(document, name, field)
Get one field value from a document.
const email = await loopar.db.getValue("Customer", "CUST-0001", "email");
loopar.db.count(document, filter)
Count records matching a filter.
const active = await loopar.db.count("Customer", { status: "Active" });
loopar.db.hasEntity(constructor, document) / loopar.db.hasTable(name)
Check whether an entity record or a physical table exists.
const exists = await loopar.db.hasTable("Customer");
Filter Conditions
Filters are plain objects. For operators, import Op from loopar:
import { loopar, Op } from "loopar";
// Exact match (AND between keys)
{ status: "Active", priority: "High" }
// Operators
{ amount: { [Op.gt]: 1000 } }
{ created_at: { [Op.gte]: "2024-01-01" } }
{ name: { [Op.like]: "%john%" } }
{ status: { [Op.in]: ["Open", "In Progress"] } }
Modify Methods
loopar.db.setValue(document, name, field, value)
Update a single field directly.
await loopar.db.setValue("Customer", "CUST-0001", "status", "VIP");
Note: This bypasses model hooks. Use
doc.save()for the full lifecycle.
loopar.db.insertRow / updateRow / deleteRow / deleteWhere
Low-level row operations used by the document layer. In application code, prefer doc.save() and doc.delete().
await loopar.db.deleteWhere("Log", { created_at: { [Op.lt]: "2024-01-01" } });
Raw SQL
loopar.db.raw(sql, replacements?)
Run a parameterized raw query.
const stats = await loopar.db.raw(`
SELECT project, COUNT(*) AS task_count
FROM Task
WHERE created_at > ?
GROUP BY project
`, ["2024-01-01"]);
Warning: Always use
?placeholders with thereplacementsarray to prevent SQL injection.
Transactions
await loopar.db.beginTransaction();
try {
const order = await loopar.newDocument("Order", { customer: "CUST-0001" });
await order.save();
await loopar.db.endTransaction(); // commit
} catch (e) {
await loopar.db.safeRollback();
throw e;
}
| Method | Description |
|---|---|
beginTransaction() | Start a transaction |
endTransaction() | Commit the transaction |
safeRollback() | Roll back safely |
BaseDocument
Base class for all Models. Extend this to add custom logic to your entities.
// customer.js (MODEL)
import { BaseDocument } from "loopar";
export default class Customer extends BaseDocument {
constructor(props) {
super(props);
}
}
Instance Properties
Field Values
Access and set field values directly.
// Read fields
const name = this.customer_name;
const email = this.email;
const status = this.status;
// Set fields
this.status = "Active";
this.total = this.subtotal + this.tax;
this.name
The document's unique identifier.
console.log(this.name); // "CUST-0001"
this.IS_NEW
Whether this is a new (unsaved) document.
async beforeSave() {
if (this.__IS_NEW__) {
this.status = this.status || "Draft";
}
}
Current user
Models don't carry a session object — use the global:
import { loopar } from "loopar";
async beforeSave() {
this.modified_by = loopar.currentUser?.name;
}
Instance Methods
this.save(options?)
Save the document to database (insert when new, update otherwise). Child tables, file uploads and the owning app's version bump are handled automatically.
// Simple save
await this.save();
// Skip validation
await this.save({ validate: false });
// Force re-writing child table rows (used by installers)
await this.save({ forceChildren: true });
Execution order: beforeSave() → validate() (unless validate: false) → INSERT/UPDATE → child tables → file uploads → afterSave()
this.delete(options?)
Delete the document. Fails with a validation error if other documents reference it, unless force is set.
await this.delete();
await this.delete({ force: true, sofDelete: false }); // hard delete
Triggers: connected-documents guard → DB delete → afterDelete()
this.values() / this.rawValues()
Get the document's data as a plain object (values() resolves formatted/computed values; rawValues() returns stored values).
const data = await this.values();
// { name: "CUST-0001", customer_name: "John", email: "john@...", ... }
Lifecycle Hooks
Override these methods to add custom logic. These are the hooks the document layer actually calls:
| Hook | When |
|---|---|
onLoad() | After the document is loaded |
beforeSave() | Before any save (insert OR update) |
validate() | During save, unless save({ validate: false }) |
afterSave() | After any save (insert OR update) |
afterDelete() | After a delete |
There are no
beforeInsert/afterInsert/beforeUpdate/afterUpdate/beforeDeletemethods on the model. For create/update/delete granularity, subscribe withloopar.hook(entity, event, callback)(events:beforeCreate,afterCreate,beforeUpdate,afterUpdate,beforeDelete,afterDelete) or branch onthis.__IS_NEW__insidebeforeSave()/afterSave().
async beforeSave()
async beforeSave() {
// Calculate totals
this.total = this.subtotal + this.tax - this.discount;
// Update full name
this.full_name = `${this.first_name} ${this.last_name}`;
if (this.__IS_NEW__) {
this.status = this.status || "Draft";
}
}
async validate()
Validate before any save operation. The base implementation validates every field against the entity metadata (required, unique, format...) — call super.validate() to keep it.
async validate() {
await super.validate();
if (this.amount < 0) {
loopar.throw("Amount cannot be negative");
}
// Check for duplicates
const existing = await loopar.db.getRow("Customer", { email: this.email });
if (existing && existing.name !== this.name) {
loopar.throw("Email already exists");
}
}
async afterSave()
async afterSave() {
if (this.__IS_NEW__) {
// Send welcome email on first save
await loopar.mail.send({
to: this.email,
subject: "Welcome!",
html: `Hello ${this.customer_name}!`
});
}
}
async afterDelete()
async afterDelete() {
console.log(`Customer ${this.name} deleted`);
}
ORM-level hooks (any entity, from anywhere)
import { loopar } from "loopar";
loopar.hook("Order", "afterCreate", async (data) => {
await loopar.newDocument("Notification", {
title: `New order ${data.name}`
}).then(n => n.save());
});
BaseController
Base class for Controllers. Only methods prefixed with action (or publicAction) are accessible via URL.
// customer-controller.js (CONTROLLER)
import { BaseController, loopar } from "loopar";
export default class CustomerController extends BaseController {
// GET/POST /api/Customer/stats
async actionStats() {
return { total: 100 };
}
}
URL Routing
The URL segment is the literal method name without its prefix — only the first letter is case-insensitive. There is no kebab-case conversion.
| Method Name | URL | Access |
|---|---|---|
actionView | /api/Entity/view?name=X | Logged-in + permission |
actionCreate | /api/Entity/create | Logged-in + permission |
actionStats | /api/Entity/stats | Logged-in + permission |
actionSendEmail | /api/Entity/sendEmail | Logged-in + permission |
publicActionTrack | /api/Entity/track | Public (no auth) |
helperMethod | — | NOT accessible |
Naming: actionMyAction → /api/Entity/myAction. When both exist, publicActionX takes precedence over actionX for the URL segment x.
The same controller serves every workspace: /desk/Entity/action renders the desk UI, /api/Entity/action returns JSON.
Instance Properties
this.data
Request data (POST body, including multipart fields and files).
async actionCreate() {
const { name, email, status } = this.data;
const customer = await loopar.newDocument("Customer", {
customer_name: name,
email,
status
});
await customer.save();
return this.success("Customer created", { name: customer.name });
}
this.query
Query-string parameters. Query params are also spread onto the controller instance (e.g. ?name=X sets this.name).
async actionView() {
const name = this.query.name; // same as this.name
const document = await loopar.getDocument(this.document, name);
return await this.render(document);
}
this.document / this.action
The entity name and action resolved from the URL (/{workspace}/{document}/{action}).
this.req / this.res
The underlying Express request and response.
async actionExport() {
this.res.setHeader("Content-Type", "text/csv");
this.res.setHeader("Content-Disposition", "attachment; filename=customers.csv");
return csvString;
}
Current user
import { loopar } from "loopar";
async actionProfile() {
const user = loopar.currentUser;
if (!user) {
loopar.throw({ code: 401, message: "Not authenticated" });
}
return await loopar.getDocument("User", user.name);
}
Response Helpers
| Helper | Result |
|---|---|
this.success(message, extra?) | { status: 200, success: true, message, ...extra, notify } |
this.error(message, extra?, status?) | { status, code, title, message, ...extra, notify } |
this.redirect(url) | Client-side redirect |
this.render(meta) | Render a page (non-API workspaces) |
this.notFound({...}) | 404 payload/page |
Pass notify: false in extra to suppress the toast.
Built-in Actions
Every entity controller inherits these from BaseController:
| Action | URL | Description |
|---|---|---|
list | /api/Entity/list | Paginated rows (default action) |
view | /api/Entity/view?name=X | Single document |
create | /api/Entity/create | New document (POST data = fields) |
update | /api/Entity/update?name=X | Save changes (POST data = fields) |
delete | /api/Entity/delete?name=X | Delete document |
bulkDelete | /api/Entity/bulkDelete | Delete many (names in body) |
search | /api/Entity/search | Select/autocomplete search |
Action Examples
Query Action
// GET /api/Customer/topCustomers?status=Active
async actionTopCustomers() {
const { status } = this.query;
const filters = {};
if (status) filters.status = status;
const customers = await loopar.db.getAll("Customer", ["name", "email"], filters);
return { customers };
}
Action with Document
// POST /api/Customer/upgradeToVip (body: { name: "CUST-0001" })
async actionUpgradeToVip() {
const { name } = this.data;
if (!name) {
loopar.throw("Customer name required");
}
const customer = await loopar.getDocument("Customer", name);
customer.status = "VIP";
await customer.save();
return this.success(`${customer.customer_name} is now VIP`);
}
Stats/Aggregation Action
// GET /api/Task/dashboard
async actionDashboard() {
const user = loopar.currentUser?.name;
const [open, inProgress, completed] = await Promise.all([
loopar.db.count("Task", { status: "Open", assigned_to: user }),
loopar.db.count("Task", { status: "In Progress", assigned_to: user }),
loopar.db.count("Task", { status: "Completed", assigned_to: user }),
]);
return { open, inProgress, completed, total: open + inProgress + completed };
}
Public Action (no auth)
// POST /api/Contact Message/submit — reachable without login
async publicActionSubmit() {
const message = await loopar.newDocument("Contact Message", this.data);
await message.save();
return this.success("Thanks! We'll get back to you.");
}
publicAction*methods skip the login, permission and CSRF checks — validate their input carefully.
Error Handling
async actionRiskyOperation() {
try {
await this.performOperation();
return this.success("Done");
} catch (error) {
console.error(error);
loopar.throw({ code: 500, message: error.message });
}
}
Declaring exposure
Static class fields fine-tune which actions are available:
export default class TaskController extends BaseController {
// Extra inherited actions to enable on this entity's views
static enabledActions = ["archive"];
}
HTTP API
Every entity controller is reachable over HTTP. The URL follows one convention everywhere:
/{workspace}/{Entity}/{action}
| Workspace | Purpose | Auth |
|---|---|---|
web (no prefix) | Public website pages | None |
desk | Admin UI | Login + CSRF, desk users only |
api | JSON surface | Login + CSRF (except publicAction*) |
portal | Authenticated end-user app | Login + CSRF |
auth | Login/register/recovery | — |
The action comes from the URL path, not from the HTTP verb.
GETandPOSTboth dispatch the same action; usePOSTwhen sending data (body fields becomethis.data).
Calling Built-in Actions
# List (paginated)
GET /api/Customer/list
# Single document
GET /api/Customer/view?name=CUST-0001
# Create (POST fields as JSON or form-data)
POST /api/Customer/create
Content-Type: application/json
{ "customer_name": "New Customer", "email": "new@example.com", "status": "Lead" }
# Update
POST /api/Customer/update?name=CUST-0001
Content-Type: application/json
{ "status": "VIP" }
# Delete
POST /api/Customer/delete?name=CUST-0001
# Bulk delete
POST /api/Customer/bulkDelete
{ "names": ["CUST-0001", "CUST-0002"] }
List pagination & filters
List state is session-backed (it's what the desk infinite list uses). Send page and per-field search terms in q as POST data:
POST /api/Customer/list
Content-Type: application/json
{ "page": 2, "q": { "status": "Active", "customer_name": "john" } }
List response:
{
"labels": ["Name", "Email", "Status"],
"fields": ["name", "email", "status"],
"rows": [
{ "id": 1, "name": "CUST-0001", "email": "john@...", "status": "Active" }
],
"pagination": {
"page": 1,
"pageSize": 10,
"totalRecords": 150,
"totalPages": 15,
"sortBy": "id",
"sortOrder": "asc"
},
"q": { "status": "Active" }
}
For programmatic queries with full control (fields, filters, ordering, limits), write a custom action that calls
loopar.getList()orloopar.db.getAll()— that's the intended pattern.
Custom Actions
A controller method becomes a URL with its prefix stripped — no kebab-case conversion, the segment is the literal method suffix:
async actionStats() { ... } // GET/POST /api/Customer/stats
async actionMarkComplete() { ... } // GET/POST /api/Task/markComplete
async publicActionTrack() { ... } // GET/POST /api/Page View/track — public
# GET action
GET /api/Customer/stats
# POST action with data
POST /api/Task/markComplete
Content-Type: application/json
{ "name": "TASK-0001" }
Success & Error Responses
Actions returning via this.success():
{
"status": 200,
"success": true,
"message": "Customer CUST-0001 saved successfully",
"name": "CUST-0001",
"notify": { "type": "success", "message": "..." }
}
Errors (thrown with loopar.throw or returned via this.error()):
{
"status": 404,
"code": 404,
"title": "Not Found",
"message": "Customer not found"
}
The HTTP status code mirrors the payload's status/code (clamped to 100–599).
| Status | Meaning |
|---|---|
400 | Bad request / Validation error |
401 | Authentication required |
403 | Permission denied / Invalid CSRF |
404 | Document or action not found |
500 | Server error |
Authentication
The /api workspace uses cookie-based sessions, not bearer tokens:
- Log in via
POST /auth/login— the server sets an httpOnly JWT cookie (loopar_token_<tenant>) and a readablecsrf-tokencookie. - Send the cookies with every request.
- For non-GET requests, echo the CSRF cookie in the
x-csrf-tokenheader (double-submit).
# 1. Login (saves cookies)
curl -c cookies.txt -X POST http://localhost:3000/auth/login \
-d "email=admin@example.com&password=secret"
# 2. Authenticated call with CSRF header
CSRF=$(grep csrf-token cookies.txt | awk '{print $7}')
curl -b cookies.txt -H "x-csrf-token: $CSRF" \
-X POST http://localhost:3000/api/Customer/update?name=CUST-0001 \
-H "Content-Type: application/json" \
-d '{"status": "VIP"}'
Actions declared as publicAction* skip login and CSRF entirely — that's the mechanism for webhooks and public endpoints.
Client-Side API
Utilities for client-side code (.jsx files inside an entity's client/ folder).
Client Contexts
An entity's client files export a class extending the context that matches the view:
// client/task-form.jsx
import FormContext from "@context/form-context";
export default class TaskForm extends FormContext {
// customize the form view
}
| Context | Import | View |
|---|---|---|
FormContext | @context/form-context | Create/update form |
ListContext | @context/list-context | List view |
ViewContext | @context/view-context | Read-only view |
ReportContext | @context/report-context | Report/table view |
Form Values (FormContext)
Form contexts wrap react-hook-form. Read and write values with:
export default class TaskForm extends FormContext {
markComplete() {
this.setValue("status", "Completed");
this.save(); // sends the form through the entity's update action
}
get statusLabel() {
return this.getValue("status");
}
}
| Method | Description |
|---|---|
this.getValue(field) | Current field value |
this.setValue(field, value) | Set a field (marks the form dirty) |
this.save() | Submit through the document's action |
this.hasChanges() | Any dirty fields? |
useDocument Hook
Inside custom components rendered within a document view, useDocument() exposes the document context:
import { useDocument } from "@context/@/document-context";
function Header() {
const { name, entity, Document, sidebarOpen, handleSetSidebarOpen } = useDocument();
return <h1>{entity}: {name}</h1>;
}
| Property | Description |
|---|---|
name | Document name |
entity | Entity name |
Document | Document metadata (entity, values, structure) |
docRef | Ref to the active form/view instance |
sidebarOpen / handleSetSidebarOpen | Sidebar state |
The client loopar object
import { loopar } from "loopar";
loopar.call(Document, action, options?)
The request method — RPC in the literal sense: invoke a controller action as if it were a local function. The URL is always POST /{Document}/{action}, no matter which workspace you are browsing (the browsing context travels in the signed X-Workspace-Token header):
// Simple call
const stats = await loopar.call("Customer", "stats");
// With data (JSON body)
const result = await loopar.call("Task", "markComplete", {
body: { name: "TASK-0001" }
});
// Callback style — without callbacks it returns a Promise
loopar.call("Customer", "stats", { success: (r) => console.log(r) });
Options: { body, query, success, error, always, freeze }.
Non-public actions require session + permission + CSRF; an action is public only when its controller declares publicAction<Name>.
There is no client-side
api/rpcsurface anymore — Loopar calling itself is always first-party. The/api/{Document}/{action}channel still exists on the server, but only for third parties (external integrations, webhooks, server-to-server).
loopar.navigate(path)
loopar.navigate("/desk/Customer/list");
loopar.navigate(`/desk/Customer/update?name=${name}`);
loopar.notify(message, type?)
loopar.notify("Saved!", "success");
loopar.notify("Something failed", "error");
UI Components
Loopar ships shadcn/ui components under the @cn alias, plus its own element components:
import { Button } from "@cn/components/ui/button";
import { Badge } from "@cn/components/ui/badge";
import { cn } from "@cn/lib/utils";
import { Link } from "@link";
import { CheckCircle, AlertTriangle } from "lucide-react";
Example: Custom Form
// client/task-form.jsx
import FormContext from "@context/form-context";
import { loopar } from "loopar";
export default class TaskForm extends FormContext {
async markComplete() {
this.setValue("status", "Completed");
this.setValue("completed_at", new Date().toISOString());
this.save();
loopar.notify("Task completed", "success");
}
}