Arslan1997 commited on
Commit
b612dd5
·
1 Parent(s): fd0c922

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
backend/app/pipeline/ingest.py CHANGED
@@ -418,8 +418,15 @@ def _plain_links(url: str) -> list[str]:
418
  return links
419
 
420
 
421
- def crawl_site(url: str, limit: int, on_found=None, on_post=None) -> list[FeedEntry]:
422
- """Find the posts on a site with no feed and read each one."""
 
 
 
 
 
 
 
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, urls)):
443
  if entry:
444
  entries.append(entry)
445
  if on_post:
446
- on_post(i + 1, len(urls))
 
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) -> list[FeedEntry]:
453
- """Posts beyond the ~20 the RSS feed carries, through Substack's public archive API."""
 
 
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 reads up to SCAN_CAP posts (or the plan's own limit, if higher). Posts past the plan's indexed_posts are
579
- # stored as locked: listed with their title and text, but with no corpus file, so chat, notebooks and generation
580
- # never see them. Upgrading re-syncs and unlocks them without asking the writer to reconnect anything.
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
- """Index posts beyond the plan limit without making them available. Returns (count, corpus paths to remove):
592
- a post that was available before (a newer post pushed it out, or the plan went down) loses its corpus file."""
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 # forces a full store (corpus file, raw HTML) once the post unlocks
609
- doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
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"Indexing {len(beyond)} more posts. Your plan makes the latest {max_posts} available")
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, your 5 latest posts",
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"All {result['found']} posts indexed. Your latest {result['available']} are available; "
96
- f"upgrade to unlock the other {result['locked']}")
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, first 5 posts indexed, 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
  {
 
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, first 5 posts indexed, 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%.
 
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, your 5 latest posts",
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
- // The ingest job reports when the plan's post cap cut the archive short.
148
- const capped = live?.result?.capped === true;
149
- const capLimit = Number(live?.result?.limit ?? posts);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ? `${posts} posts indexed, free plan limit` : `${posts} posts indexed`) : "Still indexing in the background"}</p>
314
  <h1>What should we launch first?</h1>
315
- {capped && (
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
- {capped && (
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."}