13 |
14 | {% for post in posts %}
15 | {{ post::render_preview(post = post) }}
16 | {% endfor %}
17 |
18 | {{ pagination_eggs::pagination_eggs(base = "", prev_page = filter_state.onPrevPage, next_page = filter_state.onNextPage) }}
19 |
20 |
21 |
22 |
23 | {% endblock base_contents %}
24 |
--------------------------------------------------------------------------------
/db/templates/error.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
14 | {% for post in posts %}
15 | {{ post::render_preview(post = post) }}
16 | {% endfor %}
17 |
18 | {{ pagination_eggs::pagination_eggs(base = "", prev_page = filter_state.onPrevPage, next_page = filter_state.onNextPage) }}
19 |
20 |
21 |
22 |
23 | {% endblock base_contents %}
24 |
--------------------------------------------------------------------------------
/db/templates/pagination_eggs.html:
--------------------------------------------------------------------------------
1 | {% macro pagination_eggs(base, prev_page, next_page) %}
2 |
23 | {% endmacro pagination_eggs %}
24 |
--------------------------------------------------------------------------------
/db/templates/project_profile.html:
--------------------------------------------------------------------------------
1 | {% import "project_sidebar.html" as project_sidebar %}
2 | {% import "pagination_eggs.html" as pagination_eggs %}
3 | {% import "post.html" as post %}
4 | {% extends "base.html" %}
5 |
6 | {% block title %}
7 | cohost archive! - {{ project.handle }}
8 | {% endblock title %}
9 |
10 | {% block base_header %}
11 | {% if project.headerURL %}
12 |
18 | {% endif %}
19 | {% endblock base_header %}
20 |
21 | {% block base_contents %}
22 | {{ project_sidebar::project_sidebar(project = project, description = rendered_project_description) }}
23 |
24 |
25 | {% set b = "/" ~ project.handle -%}
26 | {% if tagged %}
27 | {% set_global b = "/" ~ project.handle ~ "/tagged/" ~ tagged | urlencode -%}
28 |
37 | {% else %}
38 |
64 | {% endif %}
65 |
66 | {% for post in posts %}
67 | {{ post::render(post = post, expand = false) }}
68 | {% endfor %}
69 |
70 | {{ pagination_eggs::pagination_eggs(base = b, prev_page = filter_state.onPrevPage, next_page = filter_state.onNextPage) }}
71 |
72 |
73 |
74 | {{ project_sidebar::project_sidebar_alt(project = project) }}
75 | {% endblock base_contents %}
76 |
--------------------------------------------------------------------------------
/db/templates/project_sidebar.html:
--------------------------------------------------------------------------------
1 | {% macro contact_card_item(item) %}
2 |
29 |
30 | {{ post::render(post = post, expand = true) }}
31 |
32 |
39 |
40 |
41 |
42 | {{ project_sidebar::project_sidebar_alt(project = post.postingProject) }}
43 | {% endblock base_contents %}
44 |
--------------------------------------------------------------------------------
/db/templates/tag_feed.html:
--------------------------------------------------------------------------------
1 | {% import "post.html" as post %}
2 | {% import "pagination_eggs.html" as pagination_eggs %}
3 | {% extends "base.html" %}
4 |
5 | {% block title %}
6 | cohost archive! - #{{ tag }}
7 | {% endblock title %}
8 |
9 | {% block page_container_classes %} is-tag-feed {% endblock page_container_classes %}
10 | {% block base_contents %}
11 |
14 |
31 |
32 |
33 | {% for post in posts %}
34 | {{ post::render_preview(post = post) }}
35 | {% endfor %}
36 |
37 | {{ pagination_eggs::pagination_eggs(base = "", prev_page = filter_state.onPrevPage, next_page = filter_state.onNextPage) }}
38 |
39 |
40 |
41 |
80 | {% endblock base_contents %}
81 |
--------------------------------------------------------------------------------
/deno.json:
--------------------------------------------------------------------------------
1 | {
2 | "imports": {
3 | "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.48",
4 | "@babel/plugin-transform-class-properties": "npm:@babel/plugin-transform-class-properties@^7.25.4",
5 | "@emoji-mart/data": "npm:@emoji-mart/data@^1.2.1",
6 | "@rollup/plugin-commonjs": "npm:@rollup/plugin-commonjs@^26.0.1",
7 | "@rollup/plugin-replace": "npm:@rollup/plugin-replace@^5.0.7",
8 | "@rollup/plugin-sucrase": "npm:@rollup/plugin-sucrase@^5.0.2",
9 | "@std/path": "jsr:@std/path@^1.0.4",
10 | "entities": "npm:entities@^2.2.0",
11 | "minisearch": "npm:minisearch@^7.1.0",
12 | "remark-gfm": "npm:remark-gfm@^4.0.0",
13 | "remark-parse": "npm:remark-parse@^11.0.0",
14 | "remark-stringify": "npm:remark-stringify@^11.0.0",
15 | "rollup": "npm:rollup@^4.21.2",
16 | "unified": "npm:unified@^11.0.5"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/main.ts:
--------------------------------------------------------------------------------
1 | import {
2 | COOKIE,
3 | DATA_PORTABILITY_ARCHIVE_PATH,
4 | ENABLE_JAVASCRIPT,
5 | POSTS,
6 | PROJECTS,
7 | SKIP_POSTS,
8 | SKIP_LIKES,
9 | } from "./src/config.ts";
10 | import { CohostContext, POST_URL_REGEX } from "./src/context.ts";
11 | import { loadAllLikedPosts } from "./src/likes.ts";
12 | import { FROM_POST_PAGE_TO_ROOT, loadPostPage } from "./src/post-page.ts";
13 | import { loadAllProjectPosts } from "./src/project.ts";
14 | import { IPost } from "./src/model.ts";
15 | import { readDataPortabilityArchiveItems } from "./src/data-portability-archive.ts";
16 | import { loadCohostSource } from "./src/cohost-source.ts";
17 | import { generateAllScripts } from "./src/scripts/index.ts";
18 | import { rewritePost } from "./src/post.ts";
19 | import { generateAllIndices } from "./src/post-index.ts";
20 | import { checkForUpdates } from "./src/changelog.ts";
21 |
22 | await checkForUpdates();
23 |
24 | const ctx = new CohostContext(COOKIE, "out");
25 | await ctx.init();
26 |
27 | let isLoggedIn = false;
28 | let currentProjectHandle = null;
29 | {
30 | // check that login actually worked
31 | const loginStateResponse = await ctx.get(
32 | "https://cohost.org/api/v1/trpc/login.loggedIn,projects.listEditedProjects?batch=1&input=%7B%7D",
33 | );
34 | const loginState = await loginStateResponse.json();
35 | if (!loginState[0].result.data.loggedIn) {
36 | console.error(
37 | "\x1b[33mwarning:\nNot logged in. Please update your cookie configuration if cohost.org still exists\x1b[m\n\n",
38 | );
39 | } else {
40 | const currentProjectId = loginState[0].result.data.projectId;
41 | const currentProject = loginState[1].result.data.projects.find((proj: { projectId: number }) =>
42 | proj.projectId === currentProjectId
43 | );
44 | if (!currentProject) {
45 | throw new Error(
46 | `invalid state: logged in as project ${currentProjectId}, but this is not an edited project`,
47 | );
48 | }
49 | currentProjectHandle = currentProject.handle;
50 |
51 | console.log(
52 | `logged in as ${
53 | loginState[0].result.data.email
54 | } / @${currentProjectHandle}`,
55 | );
56 | isLoggedIn = true;
57 | }
58 | }
59 |
60 | // JSON data
61 | if (isLoggedIn) {
62 | // legacy liked posts
63 | if (await ctx.hasFile('liked.json')) {
64 | console.log('');
65 | console.log(`There’s a list of liked posts here using an older format. It’s unclear what page you were logged in as when loading them.`);
66 | let likedPostsHandle: string | null = null;
67 | if (confirm(`Did you load these liked posts as @${currentProjectHandle}?`)) {
68 | likedPostsHandle = currentProjectHandle;
69 | } else {
70 | while (true) {
71 | likedPostsHandle = prompt("What’s the handle of the page these liked posts are for?")?.trim() ?? null;
72 | if (likedPostsHandle) {
73 | if (confirm(`It was @${likedPostsHandle}?`)) {
74 | break;
75 | }
76 | } else {
77 | break;
78 | }
79 | }
80 | }
81 | if (!likedPostsHandle) {
82 | console.log('no input. exiting');
83 | Deno.exit(1);
84 | }
85 | await Deno.mkdir(ctx.getCleanPath(likedPostsHandle), { recursive: true });
86 | await Deno.rename(ctx.getCleanPath('liked.json'), ctx.getCleanPath(`${likedPostsHandle}/liked.json`));
87 | }
88 |
89 | // load all liked posts for the current page
90 | if (!(await ctx.hasFile(`${currentProjectHandle}/liked.json`)) && !SKIP_LIKES) {
91 | console.log(`loading likes for @${currentProjectHandle}`);
92 | const liked = await loadAllLikedPosts(ctx);
93 | await ctx.writeLargeJson(`${currentProjectHandle}/liked.json`, liked);
94 | }
95 |
96 | // load all project posts
97 | for (const handle of PROJECTS) {
98 | if (!(await ctx.hasFile(`${handle}/posts.json`))) {
99 | const posts = await loadAllProjectPosts(ctx, handle);
100 | await ctx.write(`${handle}/posts.json`, JSON.stringify(posts));
101 | }
102 | }
103 | } else {
104 | console.log(
105 | "\x1b[33mnot logged in: skipping liked posts and project posts \x1b[m",
106 | );
107 | }
108 |
109 | // javascript
110 | if (ENABLE_JAVASCRIPT) {
111 | const dir = await loadCohostSource(ctx);
112 | await generateAllScripts(ctx, dir);
113 | }
114 |
115 | const errors: { url: string; error: Error }[] = [];
116 |
117 | // Single post pages
118 | {
119 | const allProjectDirsProbably: string[] = [];
120 | for await (const item of Deno.readDir(ctx.getCleanPath(''))) {
121 | if (item.isDirectory) allProjectDirsProbably.push(item.name);
122 | }
123 |
124 | const likedPosts = await Promise.all(
125 | allProjectDirsProbably.map(async (handle) => {
126 | if (SKIP_LIKES) return [];
127 |
128 | const file = `${handle}/liked.json`;
129 | if (await ctx.hasFile(file)) {
130 | return ctx.readLargeJson(`${handle}/liked.json`);
131 | } else {
132 | return [];
133 | }
134 | }),
135 | ) as IPost[][];
136 |
137 | const projectPosts = await Promise.all(
138 | allProjectDirsProbably.map(async (handle) => {
139 | const file = `${handle}/posts.json`;
140 | if (await ctx.hasFile(file)) {
141 | return ctx.readJson(`${handle}/posts.json`);
142 | } else {
143 | return [];
144 | }
145 | }),
146 | ) as IPost[][];
147 |
148 | const allPosts = [
149 | ...likedPosts.flatMap(x => x),
150 | ...projectPosts.flatMap((x) => x),
151 | ];
152 |
153 | const loadPostPageAndCollectError = async (url: string) => {
154 | try {
155 | await loadPostPage(ctx, url);
156 | } catch (error) {
157 | console.error(`\x1b[31mFailed! ${error}\x1b[m`);
158 | errors.push({ url, error });
159 | }
160 | };
161 |
162 | for (const post of allPosts) {
163 | if (SKIP_POSTS.includes(post.postId)) continue;
164 |
165 | console.log(`~~ processing post ${post.singlePostPageUrl}`);
166 | await loadPostPageAndCollectError(post.singlePostPageUrl);
167 | }
168 |
169 | // it can happen that we've cached data for a post that is now a 404.
170 | // I suppose we can try loading resources for those as well?
171 | for (const post of allPosts) {
172 | try {
173 | await rewritePost(ctx, post, FROM_POST_PAGE_TO_ROOT);
174 | } catch {
175 | // oh well!!
176 | }
177 | }
178 |
179 | const dpaPostURLs: string[] = [];
180 | if (DATA_PORTABILITY_ARCHIVE_PATH) {
181 | const items = await readDataPortabilityArchiveItems(
182 | DATA_PORTABILITY_ARCHIVE_PATH,
183 | );
184 | for (const ask of items.asks) {
185 | if (ask.responsePost) {
186 | dpaPostURLs.push(ask.responsePost);
187 | }
188 | }
189 | for (const comment of items.comments) {
190 | if (comment.post) {
191 | dpaPostURLs.push(comment.post);
192 | } else {
193 | console.log(`comment ${comment.commentId} has no post`);
194 | }
195 | }
196 | }
197 |
198 | for (const post of [...POSTS, ...dpaPostURLs]) {
199 | const probablyThePostId = +(post.match(POST_URL_REGEX)?.[2] || "");
200 | if (SKIP_POSTS.includes(probablyThePostId)) continue;
201 |
202 | console.log(`~~ processing additional post ${post}`);
203 | await loadPostPageAndCollectError(post);
204 | }
205 | }
206 |
207 | {
208 | await generateAllIndices(ctx, errors);
209 | }
210 |
211 | await ctx.finalize();
212 |
213 | if (errors.length) {
214 | console.log(
215 | `\x1b[32mDone, \x1b[33mwith ${errors.length} error${
216 | errors.length === 1 ? "" : "s"
217 | }\x1b[m`,
218 | );
219 | for (const { url, error } of errors) console.log(`${url}: ${error}`);
220 | } else {
221 | console.log("\x1b[32mDone\x1b[m");
222 | }
223 |
--------------------------------------------------------------------------------
/run.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | cd "$(dirname "$0")"
3 | deno run --allow-env --allow-ffi --allow-net --allow-read --allow-write=out main.ts
4 |
--------------------------------------------------------------------------------
/src/changelog.ts:
--------------------------------------------------------------------------------
1 | import * as path from "jsr:@std/path";
2 |
3 | async function checkForUpdatesImpl() {
4 | const currentChangelog = await Deno.readTextFile(path.join(import.meta.dirname, "../CHANGELOG.txt"));
5 |
6 | const changelogLines = currentChangelog.replace(/\r\n/g, '\n').split("\n");
7 | const url = changelogLines.shift().split("url=")[1];
8 |
9 | const newestChangelogRes = await fetch(url);
10 | if (!newestChangelogRes.ok) {
11 | throw new Error(`could not fetch update information: ${await newestChangelogRes.text()}`);
12 | }
13 | const newestChangelog = await newestChangelogRes.text();
14 | const newLines = newestChangelog.split("\n");
15 | newLines.shift();
16 |
17 | const firstNonEmptyLine = changelogLines.find(item => !!item);
18 | const firstNewNonEmptyLine = newLines.find(item => !!item);
19 |
20 | const latestVersion = newLines.find(line => line.startsWith('*'));
21 | const thisVersion = changelogLines.find(line => line.startsWith('*'));
22 |
23 | if (latestVersion === thisVersion) {
24 | return;
25 | }
26 |
27 | console.error('\x1b[32m=== cohost-dl update found ===\x1b[m');
28 | console.error('maybe you want to update your downloaded version.');
29 | console.error('changes: ');
30 |
31 | for (const line of newLines) {
32 | if (line === thisVersion) break;
33 |
34 | console.error(line);
35 | }
36 |
37 | console.error('\x1b[32m=== * ===\x1b[m');
38 | }
39 |
40 | export async function checkForUpdates() {
41 | try {
42 | await checkForUpdatesImpl();
43 | } catch (error) {
44 | console.error(`error checking for updates: ${error}`);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/cohost-source.ts:
--------------------------------------------------------------------------------
1 | import { CohostContext } from "./context.ts";
2 |
3 | // a sample post we'll be using for this.
4 | // edge case that I will not be handling: user has blocked staff
5 | const SAMPLE_POST_URL = "https://cohost.org/staff/post/7611443-cohost-to-shut-down";
6 |
7 | interface ISourceMap {
8 | version: 3;
9 | file: string;
10 | mapping: string;
11 | sources: string[];
12 | sourcesContent: string[];
13 | names: string[];
14 | }
15 |
16 | /** Loads cohost frontend source and returns root path */
17 | export async function loadCohostSource(ctx: CohostContext): Promise