Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
A self-hosted workspace that unifies **tasks, calendar, notes, and mail** around shared *Spaces* — with AI-powered capture everywhere and automatic scheduling. Built for ADHD workflows: one page, one header, everything reachable in as few clicks (or keystrokes) as possible.

- **Tasks** — kanban board home (`todo / doing / blocked / done`), space filter chips, drag between columns, inline create; grouped-by-space overview (with a show-done toggle, most recently finished first) as the secondary view
- **Notes** — space-scoped markdown capture (full EasyMDE toolbar) with AI "Cleanify" and promote-selection-to-task
- **Notes** — space-scoped markdown capture (full EasyMDE toolbar) with AI "Cleanify", promote-selection-to-task, copy-raw-markdown, and public read-only sharing (a revocable `/n/<token>` link that always serves the note's latest version)
- **Mail** — register IMAP inboxes (passwords encrypted at rest), browse live, click to read, right-click an email → AI-drafted task
- **Calendar** — AI-parsed tasks auto-scheduled around your external ICS calendars and per-space time windows; drag to reschedule (and freeze)
- **Spaces** — manage your contexts (work / study / …) with per-weekday scheduling windows and a per-space **AI context markdown** that guides every AI task creation (guide, not source — never copied into tasks)
Expand Down
14 changes: 10 additions & 4 deletions doc/PROJECT_DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ simpler-smart-calendar/
│ ├── app.py # App factory only; registers blueprints
│ ├── models.py # Task, Space, ChangeLog, Note, Mailbox, CalendarSource
│ ├── routes/ # Per-domain blueprints
│ │ ├── pages.py # /, /notes (deep link), /login, /logout
│ │ ├── pages.py # /, /notes (deep link), /n/<token> (public note), /login, /logout
│ │ ├── tasks.py # /api/tasks* (CRUD, parse, freeze, reorder)
│ │ ├── spaces.py # /api/spaces*
│ │ ├── notes.py # /api/notes* (CRUD, cleanify, promote-to-task)
│ │ ├── notes.py # /api/notes* (CRUD, cleanify, promote-to-task, public share)
│ │ ├── mailboxes.py # /api/mailboxes* (CRUD, messages, add-task)
│ │ ├── calendar_sources.py# /api/calendar-sources*, /api/external-events
│ │ └── schedule.py # /api/schedule, /api/logs
Expand Down Expand Up @@ -155,7 +155,10 @@ Default spaces seeded on first run: `work` (Mon-Fri 9-17), `study` (unconstraine
All mutation routes write through `audit.record_change()` so the entity mutation and its audit row land in **one transaction**.

### `notes`
`id`, `space_id` (FK, **NOT NULL**), `title` (nullable — the list UI falls back to "Untitled"), `content_markdown` (raw markdown source), `created_at` / `updated_at`.
`id`, `space_id` (FK, **NOT NULL**), `title` (nullable — the list UI falls back to "Untitled"), `content_markdown` (raw markdown source), `created_at` / `updated_at`. `to_dict()` also exposes `public_share_token` (the token of the note's public share, or `null` when unshared).

### `note_shares`
`id`, `note_id` (FK, **NOT NULL**, **UNIQUE** — at most one share per note), `token` (**UNIQUE**, random `secrets.token_urlsafe(16)`), `created_at`. A row is the note's single public read-only credential: anyone holding `/n/<token>` can view the note. "Stop sharing" **deletes** the row (the token is revoked, never reused; re-sharing mints a fresh one). Deleting the note cascades the share away (ORM `delete-orphan`).

### `mailboxes`
| Column | Type | Description |
Expand Down Expand Up @@ -209,6 +212,9 @@ All `/api/*` routes require the session cookie (`@login_required`, JSON 401 othe
- `GET/PUT/DELETE /api/notes/<id>` — PUT re-runs the title backfill on every save: linked tasks (`note_id`) whose title is still empty take the note's title; DELETE detaches linked tasks (`note_id → NULL`)
- `POST /api/notes/<id>/cleanify` — → `{content}`; does NOT persist (the editor applies it and the debounced PUT autosave persists). Degrades to the original content on AI failure
- `POST /api/notes/<id>/promote-to-task` — `{selected_text}` → task draft DTOs (space defaulting to the note's, `note_id` provenance tag, empty AI title borrows the note's); persists nothing
- `POST /api/notes/<id>/share` — create (or return the existing) public read-only share → `{token}`; idempotent (an already-shared note keeps its token); audited (`action='share'`). The client builds the URL as `<origin>/n/<token>`
- `DELETE /api/notes/<id>/share` — revoke the public share → 204; idempotent; audited (`action='unshare'`)
- `GET /n/<token>` — **public, no auth** (the token is the credential). Server-rendered on every request, so it always shows the note's latest saved markdown, mounted in a read-only EasyMDE locked to preview mode with a "copy raw markdown" toolbar tool. An unknown/revoked token 404s (`noindex`)

### Mail (`src/routes/mailboxes.py`)
- `GET /api/mailboxes` — DTOs with `has_password`, never the password
Expand All @@ -233,7 +239,7 @@ One page, one header:
- **Destinations** are sections toggled client-side (no page reloads), deep-linkable via `#tasks / #notes / #mail / #calendar / #spaces`; the last destination is remembered (`localStorage`).
- **Tasks**: kanban board (SortableJS: cross-column drag → `PUT {status}` only; same-column drag = manual reorder nudging just the dragged task's priority via `POST /api/tasks/reorder`; Done stays completion-time ordered, no intra-column sort there; modifier+mousedown never starts a drag — Shift/Ctrl/Alt clicks stay clicks even with hand jitter, so a sloppy Shift+click can't drop the card into a neighbouring column), space filter chips (persisted; click = one space, Ctrl+click = toggle several spaces into the filter, Alt+click = exclude a space — greyed-out chip, its tasks hidden until Alt+clicked again; "All spaces" resets both), per-column "+" inline create (Enter creates in that column; `restrict_space` only sent when exactly one space is visible), Doing column's magic button → "what do you want to do?" modal → `POST /api/tasks/auto-doing` moves the AI-matched to-dos into Doing, Done column capped at 30 most recently finished (`completed_at` desc). Board ⇄ Overview toggle persisted; the Overview has a persisted "Show done" toggle listing finished tasks most-recently-finished first.
- **Calendar**: preserved behavior — FullCalendar with drag = reschedule + auto-freeze (Ctrl skips freeze), resize = duration change, sidebar task list with drag-to-reorder (same single-task priority nudge as the board).
- **Notes** (`notes.js`, `NotesView` module, lazy init): space chips like the board (click = one space, Ctrl+click = multi-space view, Alt+click = exclude a space — greyed chip, its notes hidden; rows show a space tag when several spaces are visible; new notes land in the first visible selected space), EasyMDE source editor with the standard formatting toolbar (headings, lists, quote, code, link/image, preview, side-by-side — table and fullscreen deliberately omitted; side-by-side stays inside the notes layout via `sideBySideFullscreen: false`) plus the custom add-task/Cleanify/Undo actions. Existing notes open **rendered (preview mode)** — clicking the preview switches to edit mode; new/empty notes open straight in edit mode. Deferred persistence (no empty "Untitled" rows), debounced autosave (Ctrl+Enter flushes it immediately), Cleanify + single-step Undo, promote-selection-to-task.
- **Notes** (`notes.js`, `NotesView` module, lazy init): space chips like the board (click = one space, Ctrl+click = multi-space view, Alt+click = exclude a space — greyed chip, its notes hidden; rows show a space tag when several spaces are visible; new notes land in the first visible selected space), EasyMDE source editor with the standard formatting toolbar (headings, lists, quote, code, link/image, preview, side-by-side — table and fullscreen deliberately omitted; side-by-side stays inside the notes layout via `sideBySideFullscreen: false`) plus the custom add-task/Cleanify/Undo actions and a **copy-raw-markdown** toolbar tool (copies the note's markdown source — present on both the private editor and the public share page). Existing notes open **rendered (preview mode)** — clicking the preview switches to edit mode; new/empty notes open straight in edit mode. Deferred persistence (no empty "Untitled" rows), debounced autosave (Ctrl+Enter flushes it immediately), Cleanify + single-step Undo, promote-selection-to-task. **Public sharing**: a Share button (next to Download in the notes toolbar, enabled once a note is open) POSTs `/api/notes/<id>/share`, then copies `<origin>/n/<token>` to the clipboard and flips to "Copy link"; a Stop-sharing button appears to revoke it. The `/n/<token>` page is a standalone server-rendered template (`public_note.html`) mounting a read-only, preview-locked EasyMDE that always reflects the note's latest saved markdown.
- **Spaces** (`spaces.js`, `SpacesView` module, lazy init): space list + editor — name, description, **AI context markdown** (guidance injected into every AI task prompt), and per-weekday time windows. Replaces the old header-button modal.
- **Mail** (`mail.js`, `MailView` module, lazy init): mailbox sidebar + add/edit modal, live inbox list, click a message → reader modal (full plain-text body, still read-only server-side), right-click (or Task button) → AI draft → shared confirm modal.
- **`task_draft_modal.js`**: the shared "confirm this AI task draft" modal used by both promote-to-task and email-to-task (drafts are never silently persisted).
Expand Down
22 changes: 22 additions & 0 deletions migrate_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,28 @@
'updated_at': 'DATETIME',
},
},
'note_shares': {
'create': """
CREATE TABLE note_shares (
id INTEGER NOT NULL PRIMARY KEY,
note_id INTEGER NOT NULL UNIQUE REFERENCES notes(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
created_at DATETIME
)
""",
'columns': {
'id': 'INTEGER NOT NULL PRIMARY KEY',
'note_id': 'INTEGER NOT NULL UNIQUE REFERENCES notes(id) ON DELETE CASCADE',
'token': 'VARCHAR(64) NOT NULL UNIQUE',
'created_at': 'DATETIME',
},
'indexes': {
'ix_note_shares_note_id':
'CREATE INDEX ix_note_shares_note_id ON note_shares (note_id)',
'ix_note_shares_token':
'CREATE INDEX ix_note_shares_token ON note_shares (token)',
},
},
'mailboxes': {
'create': """
CREATE TABLE mailboxes (
Expand Down
39 changes: 39 additions & 0 deletions src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,17 +216,56 @@ class Note(db.Model):

space_rel = db.relationship('Space', backref='notes', foreign_keys=[space_id])

# Public read-only share (0-or-1 per note). uselist=False makes it a scalar;
# delete-orphan means dropping the note (or detaching the share) removes the
# link so a stale token can never resolve to a note.
share = db.relationship(
'NoteShare', backref='note', uselist=False,
cascade='all, delete-orphan', lazy='selectin')

def to_dict(self):
return {
'id': self.id,
'space_id': self.space_id,
'title': self.title,
'content_markdown': self.content_markdown or '',
# Opaque token of the note's public share, or None when not shared.
# The client builds the shareable URL from its own window.origin, so
# to_dict stays request-context-free and proxy-agnostic.
'public_share_token': self.share.token if self.share else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}


class NoteShare(db.Model):
"""A public, read-only share of a Note.

One row per shared note (note_id is UNIQUE — creating a share for an
already-shared note reuses the existing token). The random `token` is the
only credential: anyone holding the `/n/<token>` URL can view the note's
latest markdown, rendered read-only. Deleting the row ("stop sharing") is
what revokes access — the token is not reused. No auth is attached to the
row itself; there is a single owner (APP_PASSWORD) and every share is that
owner's.
"""
__tablename__ = 'note_shares'

id = db.Column(db.Integer, primary_key=True)
note_id = db.Column(db.Integer, db.ForeignKey('notes.id', ondelete='CASCADE'),
nullable=False, unique=True, index=True)
token = db.Column(db.String(64), nullable=False, unique=True, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)

def to_dict(self):
return {
'id': self.id,
'note_id': self.note_id,
'token': self.token,
'created_at': self.created_at.isoformat() if self.created_at else None,
}


class Mailbox(db.Model):
"""A registered IMAP mailbox, linked to a Space.

Expand Down
40 changes: 38 additions & 2 deletions src/routes/notes.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""Notes CRUD, Cleanify, and promote-to-task routes."""
"""Notes CRUD, Cleanify, promote-to-task, and public-share routes."""

import secrets
from datetime import datetime

from flask import Blueprint, current_app, jsonify, request

from ai_parser import cleanify_note_with_ai, parse_task_with_ai
from audit import record_change
from auth import login_required
from models import db, Note, Task
from models import db, Note, NoteShare, Task
from prompt_context import build_task_parse_prompt

notes_bp = Blueprint('notes', __name__)
Expand Down Expand Up @@ -103,6 +104,41 @@ def delete_note(note_id):
return '', 204


@notes_bp.route('/api/notes/<int:note_id>/share', methods=['POST'])
@login_required
def share_note(note_id):
"""Create (or return the existing) public read-only share for a note.

Idempotent: sharing an already-shared note returns the same token rather
than minting a new one, so the previously copied link keeps working. The
response carries the token only — the client builds the URL from its own
origin (see Note.to_dict)."""
note = Note.query.get_or_404(note_id)

if note.share is None:
# token_urlsafe(16) → 22-char URL-safe token; unique+indexed in the DB.
note.share = NoteShare(token=secrets.token_urlsafe(16))
db.session.flush()
record_change('share', 'note', note.id, new={'token': note.share.token})
db.session.commit()

return jsonify({'token': note.share.token})


@notes_bp.route('/api/notes/<int:note_id>/share', methods=['DELETE'])
@login_required
def stop_sharing_note(note_id):
"""Revoke a note's public share. Idempotent — 204 whether or not one
existed. The token is dropped, not reused; re-sharing mints a fresh one."""
note = Note.query.get_or_404(note_id)
if note.share is not None:
old_token = note.share.token
note.share = None # delete-orphan drops the NoteShare row
record_change('unshare', 'note', note.id, old={'token': old_token})
db.session.commit()
return '', 204


@notes_bp.route('/api/notes/<int:note_id>/cleanify', methods=['POST'])
@login_required
def cleanify_note(note_id):
Expand Down
20 changes: 19 additions & 1 deletion src/routes/pages.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Server-rendered pages + session auth (login/logout)."""

from flask import Blueprint, current_app, jsonify, redirect, render_template, request, session, url_for
from flask import Blueprint, abort, current_app, jsonify, redirect, render_template, request, session, url_for

from models import NoteShare

pages_bp = Blueprint('pages', __name__)

Expand All @@ -23,6 +25,22 @@ def notes_page():
return redirect('/#notes')


@pages_bp.route('/n/<token>')
def public_note(token):
"""Public, read-only view of a shared note — NO auth (the token is the
credential). Built server-side on every request, so it always renders the
note's latest saved markdown. A revoked (or never-issued) token 404s."""
share = NoteShare.query.filter_by(token=token).first()
if share is None or share.note is None:
abort(404)
note = share.note
return render_template(
'public_note.html',
note_title=(note.title or '').strip() or 'Untitled note',
content_markdown=note.content_markdown or '',
)


@pages_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
Expand Down
25 changes: 24 additions & 1 deletion src/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -966,11 +966,34 @@ body {
font-weight: 500;
}

.notes-download-btn:hover {
.notes-download-btn:hover:not(:disabled) {
border-color: #667eea;
color: #667eea;
}

.notes-download-btn:disabled {
opacity: .5;
cursor: default;
}

/* Share / Download / Stop-sharing sit together on the right of the toolbar */
.notes-toolbar-actions {
display: flex;
gap: 8px;
align-items: center;
}

/* Stop-sharing reads as a "revoke" action */
.notes-stop-share-btn {
border-color: #f0b2b2;
color: #c0392b;
}

.notes-stop-share-btn:hover:not(:disabled) {
border-color: #c0392b;
color: #c0392b;
}

.space-chips {
display: flex;
gap: 6px;
Expand Down
Loading
Loading