Spaces:
Runtime error
Runtime error
Commit ·
b612dd5
1
Parent(s): fd0c922
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- backend/app/pipeline/ingest.py +32 -23
- backend/app/services/plans.py +1 -1
- backend/app/worker.py +2 -2
- backend/tests/test_locked_posts.py +91 -0
- frontend/seo/prerender.ts +1 -1
- frontend/seo/sitemap.ts +1 -1
- frontend/src/api/types.ts +5 -1
- frontend/src/components/PricingTiers.tsx +1 -1
- frontend/src/components/Reader.tsx +16 -0
- frontend/src/pages/Sources.tsx +19 -0
- frontend/src/pages/Welcome.tsx +33 -41
backend/app/pipeline/ingest.py
CHANGED
|
@@ -418,8 +418,15 @@ def _plain_links(url: str) -> list[str]:
|
|
| 418 |
return links
|
| 419 |
|
| 420 |
|
| 421 |
-
def
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
if firecrawl.enabled():
|
| 424 |
links = firecrawl.map_site(url, max(limit * 4, 100))
|
| 425 |
else:
|
|
@@ -437,20 +444,24 @@ def crawl_site(url: str, limit: int, on_found=None, on_post=None) -> list[FeedEn
|
|
| 437 |
except (FeedNotFound, httpx.HTTPError, firecrawl.FirecrawlError):
|
| 438 |
return None
|
| 439 |
|
|
|
|
| 440 |
entries: list[FeedEntry] = []
|
| 441 |
with ThreadPoolExecutor(max_workers=4) as pool:
|
| 442 |
-
for i, entry in enumerate(pool.map(read,
|
| 443 |
if entry:
|
| 444 |
entries.append(entry)
|
| 445 |
if on_post:
|
| 446 |
-
on_post(i + 1, len(
|
|
|
|
| 447 |
if not entries:
|
| 448 |
raise FeedNotFound(f"We found {len(urls)} pages on {urlparse(url).netloc} but none had article text.")
|
| 449 |
return entries
|
| 450 |
|
| 451 |
|
| 452 |
-
def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None
|
| 453 |
-
|
|
|
|
|
|
|
| 454 |
out: list[FeedEntry] = []
|
| 455 |
offset = 0
|
| 456 |
with httpx.Client(follow_redirects=True, timeout=30, headers=UA) as client:
|
|
@@ -468,6 +479,10 @@ def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=
|
|
| 468 |
continue
|
| 469 |
if len(out) + len(known_urls) >= limit:
|
| 470 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
post = client.get(f"{site}/api/v1/posts/{item['slug']}")
|
| 472 |
if post.status_code >= 400:
|
| 473 |
continue
|
|
@@ -575,9 +590,9 @@ def store_entries(
|
|
| 575 |
return indexed, skipped, changed
|
| 576 |
|
| 577 |
|
| 578 |
-
# A sync
|
| 579 |
-
#
|
| 580 |
-
#
|
| 581 |
SCAN_CAP = 200
|
| 582 |
|
| 583 |
|
|
@@ -588,14 +603,11 @@ def _newest_first(entries: list[FeedEntry]) -> list[FeedEntry]:
|
|
| 588 |
|
| 589 |
|
| 590 |
def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple[int, set[str]]:
|
| 591 |
-
"""
|
| 592 |
-
a post that was
|
| 593 |
locked = 0
|
| 594 |
remove: set[str] = set()
|
| 595 |
for entry in entries:
|
| 596 |
-
sections = entry.sections if entry.sections is not None else html_to_sections(entry.html)
|
| 597 |
-
if not sections:
|
| 598 |
-
continue
|
| 599 |
doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url))
|
| 600 |
if not doc:
|
| 601 |
doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
|
|
@@ -605,14 +617,9 @@ def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple
|
|
| 605 |
doc.path = None
|
| 606 |
doc.title = entry.title
|
| 607 |
doc.published_at = entry.published_at
|
| 608 |
-
doc.content_hash = None #
|
| 609 |
-
doc.clean_text = "
|
| 610 |
-
doc.metadata_json = {
|
| 611 |
-
**(doc.metadata_json or {}),
|
| 612 |
-
"headings": [h for h, _ in sections if h],
|
| 613 |
-
"words": len(doc.clean_text.split()),
|
| 614 |
-
"locked": True,
|
| 615 |
-
}
|
| 616 |
locked += 1
|
| 617 |
db.commit()
|
| 618 |
return locked, remove
|
|
@@ -630,6 +637,7 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
|
|
| 630 |
on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
|
| 631 |
on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
|
| 632 |
message=f"Read {done} of {total} pages"),
|
|
|
|
| 633 |
)
|
| 634 |
title = None
|
| 635 |
else:
|
|
@@ -642,6 +650,7 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
|
|
| 642 |
entries += fetch_substack_archive(
|
| 643 |
site, {e.url for e in entries}, fetch_limit,
|
| 644 |
on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"),
|
|
|
|
| 645 |
)
|
| 646 |
except (httpx.HTTPError, ValueError):
|
| 647 |
log.warning("substack archive fetch failed for %s", site, exc_info=True)
|
|
@@ -658,7 +667,7 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
|
|
| 658 |
locked = 0
|
| 659 |
if beyond:
|
| 660 |
update_job(db, job, progress=0.85,
|
| 661 |
-
message=f"
|
| 662 |
locked, remove = store_locked(db, source, beyond)
|
| 663 |
if remove:
|
| 664 |
Corpus(source.workspace_id).write_files({INDEX: rebuild_index(db, source.workspace_id)}, remove=remove)
|
|
|
|
| 418 |
return links
|
| 419 |
|
| 420 |
|
| 421 |
+
def _title_from_url(u: str) -> str:
|
| 422 |
+
slug = urlparse(u).path.rstrip("/").rsplit("/", 1)[-1]
|
| 423 |
+
slug = re.sub(r"\.[a-z0-9]+$", "", slug)
|
| 424 |
+
return slug.replace("-", " ").replace("_", " ").strip().capitalize() or urlparse(u).netloc
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def crawl_site(url: str, limit: int, on_found=None, on_post=None, read_limit: int | None = None) -> list[FeedEntry]:
|
| 428 |
+
"""Find the posts on a site with no feed and read each one. Past read_limit, pages are only listed (title from the
|
| 429 |
+
URL, no html): they show the writer what the rest of their archive holds without paying to read it."""
|
| 430 |
if firecrawl.enabled():
|
| 431 |
links = firecrawl.map_site(url, max(limit * 4, 100))
|
| 432 |
else:
|
|
|
|
| 444 |
except (FeedNotFound, httpx.HTTPError, firecrawl.FirecrawlError):
|
| 445 |
return None
|
| 446 |
|
| 447 |
+
to_read = urls if read_limit is None else urls[:read_limit]
|
| 448 |
entries: list[FeedEntry] = []
|
| 449 |
with ThreadPoolExecutor(max_workers=4) as pool:
|
| 450 |
+
for i, entry in enumerate(pool.map(read, to_read)):
|
| 451 |
if entry:
|
| 452 |
entries.append(entry)
|
| 453 |
if on_post:
|
| 454 |
+
on_post(i + 1, len(to_read))
|
| 455 |
+
entries += [FeedEntry(title=_title_from_url(u), url=u, published_at=None, html="") for u in urls[len(to_read):]]
|
| 456 |
if not entries:
|
| 457 |
raise FeedNotFound(f"We found {len(urls)} pages on {urlparse(url).netloc} but none had article text.")
|
| 458 |
return entries
|
| 459 |
|
| 460 |
|
| 461 |
+
def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None,
|
| 462 |
+
body_budget: int | None = None) -> list[FeedEntry]:
|
| 463 |
+
"""Posts beyond the ~20 the RSS feed carries, through Substack's public archive API. Only the first body_budget
|
| 464 |
+
posts get their body fetched; the rest are listed from the archive metadata alone (title, date, link)."""
|
| 465 |
out: list[FeedEntry] = []
|
| 466 |
offset = 0
|
| 467 |
with httpx.Client(follow_redirects=True, timeout=30, headers=UA) as client:
|
|
|
|
| 479 |
continue
|
| 480 |
if len(out) + len(known_urls) >= limit:
|
| 481 |
break
|
| 482 |
+
if body_budget is not None and len(out) >= body_budget:
|
| 483 |
+
out.append(FeedEntry(title=item.get("title") or "Untitled", url=url,
|
| 484 |
+
published_at=_parse_date(item.get("post_date")), html=""))
|
| 485 |
+
continue
|
| 486 |
post = client.get(f"{site}/api/v1/posts/{item['slug']}")
|
| 487 |
if post.status_code >= 400:
|
| 488 |
continue
|
|
|
|
| 590 |
return indexed, skipped, changed
|
| 591 |
|
| 592 |
|
| 593 |
+
# A sync discovers up to SCAN_CAP posts (or the plan's own limit, if higher) but only indexes the plan's
|
| 594 |
+
# indexed_posts. The rest are listed, not indexed: title, date and link only, no text and no corpus file. They exist
|
| 595 |
+
# to show the writer how much of their archive is waiting behind an upgrade. Upgrading re-syncs and indexes them.
|
| 596 |
SCAN_CAP = 200
|
| 597 |
|
| 598 |
|
|
|
|
| 603 |
|
| 604 |
|
| 605 |
def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple[int, set[str]]:
|
| 606 |
+
"""List posts beyond the plan limit without indexing them: title, date and link only. Returns (count, corpus paths
|
| 607 |
+
to remove): a post that was indexed before (a newer post pushed it out, or the plan went down) loses its file."""
|
| 608 |
locked = 0
|
| 609 |
remove: set[str] = set()
|
| 610 |
for entry in entries:
|
|
|
|
|
|
|
|
|
|
| 611 |
doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url))
|
| 612 |
if not doc:
|
| 613 |
doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
|
|
|
|
| 617 |
doc.path = None
|
| 618 |
doc.title = entry.title
|
| 619 |
doc.published_at = entry.published_at
|
| 620 |
+
doc.content_hash = None # indexed from scratch once the plan allows it
|
| 621 |
+
doc.clean_text = ""
|
| 622 |
+
doc.metadata_json = {"locked": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
locked += 1
|
| 624 |
db.commit()
|
| 625 |
return locked, remove
|
|
|
|
| 637 |
on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
|
| 638 |
on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
|
| 639 |
message=f"Read {done} of {total} pages"),
|
| 640 |
+
read_limit=max_posts,
|
| 641 |
)
|
| 642 |
title = None
|
| 643 |
else:
|
|
|
|
| 650 |
entries += fetch_substack_archive(
|
| 651 |
site, {e.url for e in entries}, fetch_limit,
|
| 652 |
on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"),
|
| 653 |
+
body_budget=max(0, max_posts - len(entries)),
|
| 654 |
)
|
| 655 |
except (httpx.HTTPError, ValueError):
|
| 656 |
log.warning("substack archive fetch failed for %s", site, exc_info=True)
|
|
|
|
| 667 |
locked = 0
|
| 668 |
if beyond:
|
| 669 |
update_job(db, job, progress=0.85,
|
| 670 |
+
message=f"Found {len(beyond)} more posts. Your plan indexes the latest {max_posts}")
|
| 671 |
locked, remove = store_locked(db, source, beyond)
|
| 672 |
if remove:
|
| 673 |
Corpus(source.workspace_id).write_files({INDEX: rebuild_index(db, source.workspace_id)}, remove=remove)
|
backend/app/services/plans.py
CHANGED
|
@@ -41,7 +41,7 @@ PLANS: dict[str, Plan] = {
|
|
| 41 |
voice_cloning=False,
|
| 42 |
brand_kit=False,
|
| 43 |
features=(
|
| 44 |
-
"1 source,
|
| 45 |
"Grounded research chat with citations",
|
| 46 |
"3 min of audio overviews a month",
|
| 47 |
"1 min of video a month",
|
|
|
|
| 41 |
voice_cloning=False,
|
| 42 |
brand_kit=False,
|
| 43 |
features=(
|
| 44 |
+
"1 source, whole archive indexed, latest 5 posts available",
|
| 45 |
"Grounded research chat with citations",
|
| 46 |
"3 min of audio overviews a month",
|
| 47 |
"1 min of video a month",
|
backend/app/worker.py
CHANGED
|
@@ -92,8 +92,8 @@ def handle_ingest(db: Session, job: Job):
|
|
| 92 |
raise
|
| 93 |
_after_import(db, job, source.workspace_id, result.get("changed", []))
|
| 94 |
if result.get("locked"):
|
| 95 |
-
return Done(result, f"
|
| 96 |
-
f"upgrade to
|
| 97 |
return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
|
| 98 |
|
| 99 |
|
|
|
|
| 92 |
raise
|
| 93 |
_after_import(db, job, source.workspace_id, result.get("changed", []))
|
| 94 |
if result.get("locked"):
|
| 95 |
+
return Done(result, f"Found {result['found']} posts. Your latest {result['available']} are indexed; "
|
| 96 |
+
f"upgrade to index the other {result['locked']}")
|
| 97 |
return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
|
| 98 |
|
| 99 |
|
backend/tests/test_locked_posts.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Posts past the plan's indexed_posts are listed, not indexed: no text, no corpus file, indexed on a bigger plan."""
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import UTC, datetime, timedelta
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import select
|
| 7 |
+
|
| 8 |
+
from app.models import Document, Job, Source, Workspace
|
| 9 |
+
from app.pipeline import ingest
|
| 10 |
+
from app.pipeline.ingest import FeedEntry, ingest_source
|
| 11 |
+
from app.routers.sources import is_locked
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _entries(n: int) -> list[FeedEntry]:
|
| 15 |
+
start = datetime(2026, 1, 1, tzinfo=UTC)
|
| 16 |
+
# Oldest first on purpose: ingest must pick the newest posts regardless of feed order.
|
| 17 |
+
return [
|
| 18 |
+
FeedEntry(title=f"Post {i}", url=f"https://ada.example/p/{i}", published_at=start + timedelta(days=i),
|
| 19 |
+
html=f"<h2>Point {i}</h2><p>Paragraph for post number {i} with enough words to keep.</p>")
|
| 20 |
+
for i in range(n)
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _setup(db, monkeypatch, n: int):
|
| 25 |
+
monkeypatch.setattr(ingest, "fetch_feed", lambda url, limit: ("Ada", _entries(n)[:limit]))
|
| 26 |
+
ws = Workspace(name="w", owner_id=uuid.uuid4())
|
| 27 |
+
db.add(ws)
|
| 28 |
+
db.flush()
|
| 29 |
+
source = Source(workspace_id=ws.id, feed_url="https://ada.example/feed", platform="rss", title="Ada")
|
| 30 |
+
db.add(source)
|
| 31 |
+
db.flush()
|
| 32 |
+
job = Job(workspace_id=ws.id, kind="ingest", params={"source_id": str(source.id)})
|
| 33 |
+
db.add(job)
|
| 34 |
+
db.commit()
|
| 35 |
+
return ws, source, job
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _docs(db, source):
|
| 39 |
+
return {d.title: d for d in db.scalars(select(Document).where(Document.source_id == source.id))}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_lists_every_post_but_indexes_only_the_latest(db_session, monkeypatch):
|
| 43 |
+
ws, source, job = _setup(db_session, monkeypatch, 12)
|
| 44 |
+
result = ingest_source(db_session, source, job, max_posts=5)
|
| 45 |
+
|
| 46 |
+
assert result["found"] == 12 and result["available"] == 5 and result["locked"] == 7 and result["capped"]
|
| 47 |
+
docs = _docs(db_session, source)
|
| 48 |
+
assert len(docs) == 12
|
| 49 |
+
available = {t for t, d in docs.items() if d.path}
|
| 50 |
+
assert available == {f"Post {i}" for i in range(7, 12)} # the five newest
|
| 51 |
+
locked = [d for d in docs.values() if not d.path]
|
| 52 |
+
assert len(locked) == 7
|
| 53 |
+
assert all(is_locked(d) and d.clean_text == "" and d.title for d in locked)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_upgrade_unlocks_and_newer_posts_relock_the_oldest(db_session, monkeypatch):
|
| 57 |
+
ws, source, job = _setup(db_session, monkeypatch, 12)
|
| 58 |
+
ingest_source(db_session, source, job, max_posts=5)
|
| 59 |
+
|
| 60 |
+
ingest_source(db_session, source, job, max_posts=500)
|
| 61 |
+
docs = _docs(db_session, source)
|
| 62 |
+
assert all(d.path for d in docs.values())
|
| 63 |
+
assert not any(is_locked(d) for d in docs.values())
|
| 64 |
+
|
| 65 |
+
ingest_source(db_session, source, job, max_posts=5)
|
| 66 |
+
docs = _docs(db_session, source)
|
| 67 |
+
assert sum(1 for d in docs.values() if d.path) == 5
|
| 68 |
+
assert docs["Post 0"].path is None and is_locked(docs["Post 0"])
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_scan_is_capped_per_sync(db_session, monkeypatch):
|
| 72 |
+
ws, source, job = _setup(db_session, monkeypatch, ingest.SCAN_CAP + 50)
|
| 73 |
+
result = ingest_source(db_session, source, job, max_posts=5)
|
| 74 |
+
assert result["found"] == ingest.SCAN_CAP
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_crawl_reads_only_the_pages_it_will_index(monkeypatch):
|
| 78 |
+
read = []
|
| 79 |
+
urls = [f"https://ada.example/posts/post-{i}" for i in range(20)]
|
| 80 |
+
monkeypatch.setattr(ingest, "_plain_links", lambda url: urls)
|
| 81 |
+
monkeypatch.setattr(ingest, "_post_urls", lambda links, url, limit: links[:limit])
|
| 82 |
+
|
| 83 |
+
def fake_extract(u):
|
| 84 |
+
read.append(u)
|
| 85 |
+
return FeedEntry(title=u, url=u, published_at=None, html="<p>body text for the page</p>")
|
| 86 |
+
|
| 87 |
+
monkeypatch.setattr(ingest, "extract_article", fake_extract)
|
| 88 |
+
entries = ingest.crawl_site("https://ada.example", 200, read_limit=5)
|
| 89 |
+
assert len(read) == 5 and len(entries) == 20
|
| 90 |
+
assert all(e.html == "" for e in entries[5:])
|
| 91 |
+
assert entries[5].title == "Post 5"
|
frontend/seo/prerender.ts
CHANGED
|
@@ -182,7 +182,7 @@ function routes(): Rendered[] {
|
|
| 182 |
description:
|
| 183 |
"Notestack pricing: Free ($0), Writer ($24.99 a month, $18.99 billed annually) and Studio ($48.99 a month, $36.99 billed annually). Annual billing saves 24%.",
|
| 184 |
body: page(
|
| 185 |
-
`<h1>Pick your trajectory</h1><ul><li><h2>Free</h2><p>$0. 1 source,
|
| 186 |
),
|
| 187 |
},
|
| 188 |
{
|
|
|
|
| 182 |
description:
|
| 183 |
"Notestack pricing: Free ($0), Writer ($24.99 a month, $18.99 billed annually) and Studio ($48.99 a month, $36.99 billed annually). Annual billing saves 24%.",
|
| 184 |
body: page(
|
| 185 |
+
`<h1>Pick your trajectory</h1><ul><li><h2>Free</h2><p>$0. 1 source, whole archive indexed with your latest 5 posts available, 3 audio minutes, 1 video minute, 2 Launch Kits a month.</p></li><li><h2>Writer</h2><p>$24.99 a month ($18.99 a month billed annually). 3 sources, 500 posts, 60 audio minutes, 30 video minutes, 50 Launch Kits.</p></li><li><h2>Studio</h2><p>$48.99 a month ($36.99 a month billed annually). 10 sources, 5,000 posts, 240 audio minutes, 120 video minutes, unlimited Launch Kits.</p></li></ul><p><a href="/auth?mode=signup">Start free</a></p>`,
|
| 186 |
),
|
| 187 |
},
|
| 188 |
{
|
frontend/seo/sitemap.ts
CHANGED
|
@@ -96,7 +96,7 @@ differs from NotebookLM.
|
|
| 96 |
SEO pack, carousel).
|
| 97 |
- **Publishing:** a Launchpad calendar that auto posts to X, LinkedIn and Bluesky, plus a topic map of rising and
|
| 98 |
dormant themes and evergreen resurfacing of old posts.
|
| 99 |
-
- **Pricing (USD):** Free: 1 source,
|
| 100 |
Writer: $24.99 a month ($18.99 a month billed annually): 3 sources, 500 posts, 60 audio minutes, 30 video minutes,
|
| 101 |
50 launch kits. Studio: $48.99 a month ($36.99 a month billed annually): 10 sources, 5,000 posts, 240 audio
|
| 102 |
minutes, 120 video minutes, unlimited launch kits. Annual billing saves 24%.
|
|
|
|
| 96 |
SEO pack, carousel).
|
| 97 |
- **Publishing:** a Launchpad calendar that auto posts to X, LinkedIn and Bluesky, plus a topic map of rising and
|
| 98 |
dormant themes and evergreen resurfacing of old posts.
|
| 99 |
+
- **Pricing (USD):** Free: 1 source, whole archive indexed with the latest 5 posts available, 3 audio minutes, 1 video minute, 2 launch kits a month.
|
| 100 |
Writer: $24.99 a month ($18.99 a month billed annually): 3 sources, 500 posts, 60 audio minutes, 30 video minutes,
|
| 101 |
50 launch kits. Studio: $48.99 a month ($36.99 a month billed annually): 10 sources, 5,000 posts, 240 audio
|
| 102 |
minutes, 120 video minutes, unlimited launch kits. Annual billing saves 24%.
|
frontend/src/api/types.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type Source = {
|
|
| 25 |
sync_error: string | null;
|
| 26 |
last_synced_at: string | null;
|
| 27 |
document_count: number;
|
|
|
|
|
|
|
| 28 |
is_imports: boolean;
|
| 29 |
};
|
| 30 |
|
|
@@ -38,9 +40,11 @@ export type Doc = {
|
|
| 38 |
published_at: string | null;
|
| 39 |
words: number;
|
| 40 |
evergreen_score: number | null;
|
|
|
|
|
|
|
| 41 |
};
|
| 42 |
|
| 43 |
-
export type DocDetail = Doc & { lines: string[] };
|
| 44 |
|
| 45 |
export type Page<T> = { total: number; items: T[] };
|
| 46 |
|
|
|
|
| 25 |
sync_error: string | null;
|
| 26 |
last_synced_at: string | null;
|
| 27 |
document_count: number;
|
| 28 |
+
/** Indexed but past the plan's post limit: listed, unlocked by upgrading. */
|
| 29 |
+
locked_count: number;
|
| 30 |
is_imports: boolean;
|
| 31 |
};
|
| 32 |
|
|
|
|
| 40 |
published_at: string | null;
|
| 41 |
words: number;
|
| 42 |
evergreen_score: number | null;
|
| 43 |
+
/** Indexed but past the plan's post limit: preview only until the workspace upgrades. */
|
| 44 |
+
locked: boolean;
|
| 45 |
};
|
| 46 |
|
| 47 |
+
export type DocDetail = Doc & { lines: string[]; preview?: boolean; limit?: number };
|
| 48 |
|
| 49 |
export type Page<T> = { total: number; items: T[] };
|
| 50 |
|
frontend/src/components/PricingTiers.tsx
CHANGED
|
@@ -26,7 +26,7 @@ const FALLBACK: Plan[] = [
|
|
| 26 |
tagline: "For trying Notestack on your archive",
|
| 27 |
price_monthly_usd: 0,
|
| 28 |
features: [
|
| 29 |
-
"1 source,
|
| 30 |
"Grounded research chat with citations",
|
| 31 |
"3 min of audio overviews a month",
|
| 32 |
"1 min of video a month",
|
|
|
|
| 26 |
tagline: "For trying Notestack on your archive",
|
| 27 |
price_monthly_usd: 0,
|
| 28 |
features: [
|
| 29 |
+
"1 source, whole archive indexed, latest 5 posts available",
|
| 30 |
"Grounded research chat with citations",
|
| 31 |
"3 min of audio overviews a month",
|
| 32 |
"1 min of video a month",
|
frontend/src/components/Reader.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useEffect, useRef, useState } from "react";
|
| 2 |
import { docsApi } from "../api/endpoints";
|
| 3 |
import type { DocDetail } from "../api/types";
|
|
|
|
| 4 |
import { Drawer, formatDate, Loading } from "./ui";
|
| 5 |
|
| 6 |
/** Reads a post from the corpus with the exact line numbers citations point at. */
|
|
@@ -16,6 +17,7 @@ export function Reader({
|
|
| 16 |
const [doc, setDoc] = useState<DocDetail | null>(null);
|
| 17 |
const [error, setError] = useState<string | null>(null);
|
| 18 |
const first = useRef<HTMLDivElement>(null);
|
|
|
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
setDoc(null);
|
|
@@ -69,6 +71,20 @@ export function Reader({
|
|
| 69 |
);
|
| 70 |
})}
|
| 71 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</article>
|
| 73 |
)}
|
| 74 |
</Drawer>
|
|
|
|
| 1 |
import { useEffect, useRef, useState } from "react";
|
| 2 |
import { docsApi } from "../api/endpoints";
|
| 3 |
import type { DocDetail } from "../api/types";
|
| 4 |
+
import { useUpgrade } from "../hooks/useUpgrade";
|
| 5 |
import { Drawer, formatDate, Loading } from "./ui";
|
| 6 |
|
| 7 |
/** Reads a post from the corpus with the exact line numbers citations point at. */
|
|
|
|
| 17 |
const [doc, setDoc] = useState<DocDetail | null>(null);
|
| 18 |
const [error, setError] = useState<string | null>(null);
|
| 19 |
const first = useRef<HTMLDivElement>(null);
|
| 20 |
+
const { openUpgrade } = useUpgrade();
|
| 21 |
|
| 22 |
useEffect(() => {
|
| 23 |
setDoc(null);
|
|
|
|
| 71 |
);
|
| 72 |
})}
|
| 73 |
</div>
|
| 74 |
+
{doc.preview && (
|
| 75 |
+
<div className="welcome-cap locked-cap">
|
| 76 |
+
<div>
|
| 77 |
+
<strong>This post is indexed but locked</strong>
|
| 78 |
+
<p className="muted small">
|
| 79 |
+
Your plan makes your latest {doc.limit ?? 5} posts available. Upgrade to read it in full and use it in
|
| 80 |
+
notebooks, audio, video and Launch Kits.
|
| 81 |
+
</p>
|
| 82 |
+
</div>
|
| 83 |
+
<button type="button" className="btn btn-small btn-primary" onClick={() => openUpgrade("indexed_posts")}>
|
| 84 |
+
Upgrade to unlock
|
| 85 |
+
</button>
|
| 86 |
+
</div>
|
| 87 |
+
)}
|
| 88 |
</article>
|
| 89 |
)}
|
| 90 |
</Drawer>
|
frontend/src/pages/Sources.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type { Doc, Job, Source } from "../api/types";
|
|
| 4 |
import { Reader } from "../components/Reader";
|
| 5 |
import { ConfirmButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill, Tabs } from "../components/ui";
|
| 6 |
import { useJobMap } from "../hooks/useJob";
|
|
|
|
| 7 |
|
| 8 |
type AddMode = "feed" | "url" | "file";
|
| 9 |
|
|
@@ -86,6 +87,7 @@ export function AddSource({ onAdded }: { onAdded: (source: Source, job: Job) =>
|
|
| 86 |
|
| 87 |
export default function Sources() {
|
| 88 |
const [sources, setSources] = useState<Source[] | null>(null);
|
|
|
|
| 89 |
const [docs, setDocs] = useState<Doc[]>([]);
|
| 90 |
const [total, setTotal] = useState(0);
|
| 91 |
const [q, setQ] = useState("");
|
|
@@ -160,6 +162,7 @@ export default function Sources() {
|
|
| 160 |
<strong>{s.title ?? s.feed_url}</strong>
|
| 161 |
<span className="mono muted">
|
| 162 |
{s.is_imports ? "imports" : s.platform} · {s.document_count} posts
|
|
|
|
| 163 |
{s.last_synced_at ? ` · synced ${formatDate(s.last_synced_at, true)}` : ""}
|
| 164 |
</span>
|
| 165 |
</div>
|
|
@@ -171,6 +174,17 @@ export default function Sources() {
|
|
| 171 |
</a>
|
| 172 |
)}
|
| 173 |
{job && (active || job.status === "failed") && <JobProgress job={job} compact />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
{s.sync_status === "error" && s.sync_error && !active && <p className="error-text">{s.sync_error}</p>}
|
| 175 |
<footer className="row">
|
| 176 |
{!s.is_imports && (
|
|
@@ -222,6 +236,11 @@ export default function Sources() {
|
|
| 222 |
<button className="link-btn doc-title" onClick={() => setReading(d.id)}>
|
| 223 |
{d.title}
|
| 224 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
<span className="mono muted">
|
| 226 |
{d.source_title ?? ""} · {formatDate(d.published_at)} · {d.words} words
|
| 227 |
</span>
|
|
|
|
| 4 |
import { Reader } from "../components/Reader";
|
| 5 |
import { ConfirmButton, EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, StatusPill, Tabs } from "../components/ui";
|
| 6 |
import { useJobMap } from "../hooks/useJob";
|
| 7 |
+
import { useUpgrade } from "../hooks/useUpgrade";
|
| 8 |
|
| 9 |
type AddMode = "feed" | "url" | "file";
|
| 10 |
|
|
|
|
| 87 |
|
| 88 |
export default function Sources() {
|
| 89 |
const [sources, setSources] = useState<Source[] | null>(null);
|
| 90 |
+
const { openUpgrade } = useUpgrade();
|
| 91 |
const [docs, setDocs] = useState<Doc[]>([]);
|
| 92 |
const [total, setTotal] = useState(0);
|
| 93 |
const [q, setQ] = useState("");
|
|
|
|
| 162 |
<strong>{s.title ?? s.feed_url}</strong>
|
| 163 |
<span className="mono muted">
|
| 164 |
{s.is_imports ? "imports" : s.platform} · {s.document_count} posts
|
| 165 |
+
{s.locked_count > 0 ? ` (${s.document_count - s.locked_count} available, ${s.locked_count} locked)` : ""}
|
| 166 |
{s.last_synced_at ? ` · synced ${formatDate(s.last_synced_at, true)}` : ""}
|
| 167 |
</span>
|
| 168 |
</div>
|
|
|
|
| 174 |
</a>
|
| 175 |
)}
|
| 176 |
{job && (active || job.status === "failed") && <JobProgress job={job} compact />}
|
| 177 |
+
{s.locked_count > 0 && !active && (
|
| 178 |
+
<div className="welcome-cap locked-cap">
|
| 179 |
+
<div>
|
| 180 |
+
<strong>{s.locked_count} posts indexed and locked</strong>
|
| 181 |
+
<p className="muted small">Your plan makes your latest posts available. Upgrade to unlock the whole archive.</p>
|
| 182 |
+
</div>
|
| 183 |
+
<button type="button" className="btn btn-small btn-primary" onClick={() => openUpgrade("indexed_posts")}>
|
| 184 |
+
Upgrade to unlock
|
| 185 |
+
</button>
|
| 186 |
+
</div>
|
| 187 |
+
)}
|
| 188 |
{s.sync_status === "error" && s.sync_error && !active && <p className="error-text">{s.sync_error}</p>}
|
| 189 |
<footer className="row">
|
| 190 |
{!s.is_imports && (
|
|
|
|
| 236 |
<button className="link-btn doc-title" onClick={() => setReading(d.id)}>
|
| 237 |
{d.title}
|
| 238 |
</button>
|
| 239 |
+
{d.locked && (
|
| 240 |
+
<span className="mono muted small" title="Indexed. Upgrade to use this post">
|
| 241 |
+
Locked
|
| 242 |
+
</span>
|
| 243 |
+
)}
|
| 244 |
<span className="mono muted">
|
| 245 |
{d.source_title ?? ""} · {formatDate(d.published_at)} · {d.words} words
|
| 246 |
</span>
|
frontend/src/pages/Welcome.tsx
CHANGED
|
@@ -144,9 +144,34 @@ export default function Welcome() {
|
|
| 144 |
|
| 145 |
const indexingDone = live?.status === "done";
|
| 146 |
const indexingFailed = live?.status === "failed";
|
| 147 |
-
//
|
| 148 |
-
const
|
| 149 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
const first = (user.name ?? user.email).split(/[ @]/)[0];
|
| 151 |
|
| 152 |
const finish = async (chosen: Goal | null) => {
|
|
@@ -288,6 +313,7 @@ export default function Welcome() {
|
|
| 288 |
<p className="eyebrow">{source?.title ?? "Your archive"}</p>
|
| 289 |
<h1>{indexingFailed ? "That did not land" : "Bringing your posts into orbit"}</h1>
|
| 290 |
{live && <JobProgress job={live} />}
|
|
|
|
| 291 |
{indexingFailed ? (
|
| 292 |
<div className="row">
|
| 293 |
<button className="btn btn-primary" onClick={() => setStep("connect")}>
|
|
@@ -310,26 +336,9 @@ export default function Welcome() {
|
|
| 310 |
|
| 311 |
{step === "goal" && (
|
| 312 |
<section className="card welcome-card">
|
| 313 |
-
<p className="eyebrow">{indexingDone ? (capped ? `${
|
| 314 |
<h1>What should we launch first?</h1>
|
| 315 |
-
{
|
| 316 |
-
<div className="welcome-cap">
|
| 317 |
-
<div>
|
| 318 |
-
<strong>Your free plan indexed your newest {capLimit} posts</strong>
|
| 319 |
-
<p className="muted small">Your archive has more. Upgrade to bring every post into orbit, so answers, audio and Launch Kits draw on all of it.</p>
|
| 320 |
-
</div>
|
| 321 |
-
<button
|
| 322 |
-
type="button"
|
| 323 |
-
className="btn btn-small"
|
| 324 |
-
onClick={() => {
|
| 325 |
-
markOnboarded();
|
| 326 |
-
navigate("/app?upgrade=1", { replace: true });
|
| 327 |
-
}}
|
| 328 |
-
>
|
| 329 |
-
Index everything
|
| 330 |
-
</button>
|
| 331 |
-
</div>
|
| 332 |
-
)}
|
| 333 |
<div className="goal-grid">
|
| 334 |
{GOALS.map((g) => (
|
| 335 |
<button key={g.id} type="button" className={`goal${goal === g.id ? " on" : ""}`} onClick={() => setGoal(g.id)} aria-pressed={goal === g.id}>
|
|
@@ -350,26 +359,9 @@ export default function Welcome() {
|
|
| 350 |
{step === "ready" && (
|
| 351 |
<section className="card welcome-card">
|
| 352 |
<p className="eyebrow">Pre-flight check complete</p>
|
| 353 |
-
<h1>{indexingDone ? `${posts} posts are in orbit` : "Almost there"}</h1>
|
| 354 |
{!indexingDone && live && <JobProgress job={live} />}
|
| 355 |
-
{
|
| 356 |
-
<div className="welcome-cap">
|
| 357 |
-
<div>
|
| 358 |
-
<strong>Your free plan indexed your newest {capLimit} posts</strong>
|
| 359 |
-
<p className="muted small">Your archive has more. Upgrade to bring every post into orbit, so answers, audio and Launch Kits draw on all of it.</p>
|
| 360 |
-
</div>
|
| 361 |
-
<button
|
| 362 |
-
type="button"
|
| 363 |
-
className="btn btn-small"
|
| 364 |
-
onClick={() => {
|
| 365 |
-
markOnboarded();
|
| 366 |
-
navigate("/app?upgrade=1", { replace: true });
|
| 367 |
-
}}
|
| 368 |
-
>
|
| 369 |
-
Index everything
|
| 370 |
-
</button>
|
| 371 |
-
</div>
|
| 372 |
-
)}
|
| 373 |
<p className="muted">
|
| 374 |
{goal === "ask" && "We will put your whole archive in a notebook so you can start asking right away."}
|
| 375 |
{goal === "audio" && "We will put your archive in a notebook with the audio studio ready."}
|
|
|
|
| 144 |
|
| 145 |
const indexingDone = live?.status === "done";
|
| 146 |
const indexingFailed = live?.status === "failed";
|
| 147 |
+
// Every post is indexed; the ingest job reports how many the plan makes available and how many are locked.
|
| 148 |
+
const locked = Number(live?.result?.locked ?? 0);
|
| 149 |
+
const capped = indexingDone && locked > 0;
|
| 150 |
+
const available = Number(live?.result?.available ?? posts);
|
| 151 |
+
const found = Number(live?.result?.found ?? posts);
|
| 152 |
+
const lockedNotice = capped ? (
|
| 153 |
+
<div className="welcome-cap">
|
| 154 |
+
<div>
|
| 155 |
+
<strong>
|
| 156 |
+
All {found} posts indexed. Your latest {available} are available on the free plan.
|
| 157 |
+
</strong>
|
| 158 |
+
<p className="muted small">
|
| 159 |
+
Upgrade to unlock the other {locked}, so answers, audio and Launch Kits draw on your whole archive. Nothing to
|
| 160 |
+
reconnect: they unlock the moment you upgrade.
|
| 161 |
+
</p>
|
| 162 |
+
</div>
|
| 163 |
+
<button
|
| 164 |
+
type="button"
|
| 165 |
+
className="btn btn-small"
|
| 166 |
+
onClick={() => {
|
| 167 |
+
markOnboarded();
|
| 168 |
+
navigate("/app?upgrade=1", { replace: true });
|
| 169 |
+
}}
|
| 170 |
+
>
|
| 171 |
+
Upgrade to unlock {locked}
|
| 172 |
+
</button>
|
| 173 |
+
</div>
|
| 174 |
+
) : null;
|
| 175 |
const first = (user.name ?? user.email).split(/[ @]/)[0];
|
| 176 |
|
| 177 |
const finish = async (chosen: Goal | null) => {
|
|
|
|
| 313 |
<p className="eyebrow">{source?.title ?? "Your archive"}</p>
|
| 314 |
<h1>{indexingFailed ? "That did not land" : "Bringing your posts into orbit"}</h1>
|
| 315 |
{live && <JobProgress job={live} />}
|
| 316 |
+
{lockedNotice}
|
| 317 |
{indexingFailed ? (
|
| 318 |
<div className="row">
|
| 319 |
<button className="btn btn-primary" onClick={() => setStep("connect")}>
|
|
|
|
| 336 |
|
| 337 |
{step === "goal" && (
|
| 338 |
<section className="card welcome-card">
|
| 339 |
+
<p className="eyebrow">{indexingDone ? (capped ? `${available} of ${found} posts available` : `${posts} posts indexed`) : "Still indexing in the background"}</p>
|
| 340 |
<h1>What should we launch first?</h1>
|
| 341 |
+
{lockedNotice}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
<div className="goal-grid">
|
| 343 |
{GOALS.map((g) => (
|
| 344 |
<button key={g.id} type="button" className={`goal${goal === g.id ? " on" : ""}`} onClick={() => setGoal(g.id)} aria-pressed={goal === g.id}>
|
|
|
|
| 359 |
{step === "ready" && (
|
| 360 |
<section className="card welcome-card">
|
| 361 |
<p className="eyebrow">Pre-flight check complete</p>
|
| 362 |
+
<h1>{indexingDone ? `${capped ? available : posts} posts are in orbit` : "Almost there"}</h1>
|
| 363 |
{!indexingDone && live && <JobProgress job={live} />}
|
| 364 |
+
{lockedNotice}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
<p className="muted">
|
| 366 |
{goal === "ask" && "We will put your whole archive in a notebook so you can start asking right away."}
|
| 367 |
{goal === "audio" && "We will put your archive in a notebook with the audio studio ready."}
|