From 16a49c71c15bdc3a8fc3a2d1a430c56a0035049e Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 4 Apr 2026 23:07:44 -0700 Subject: [PATCH] feat: file viewer ported to marko a true bug for bug port. no closed issues --- framework/incremental.ts | 6 +- framework/lib/add-script.marko | 2 +- lib/arrays.ts | 20 + lib/math.ts | 8 + lib/string.ts | 26 +- src/backend.ts | 2 +- src/file-viewer/{backend.tsx => backend.ts} | 34 +- src/file-viewer/bin/file-scan.ts | 10 +- src/file-viewer/{views => }/clofi.client.ts | 5 +- src/file-viewer/cotyledon.tsx | 376 ------- src/file-viewer/format.ts | 247 ----- src/file-viewer/highlight.ts | 14 +- src/file-viewer/pages/file.404.marko | 23 + src/file-viewer/pages/file.404.tsx | 34 - .../pages/file.cotyledon-enterance.marko | 17 + .../pages/file.cotyledon-enterance.tsx | 27 - .../pages/file.cotyledon-speedbump.marko | 19 + .../pages/file.cotyledon-speedbump.tsx | 27 - src/file-viewer/rsync.ts | 4 +- src/file-viewer/tags/cotyledon-ending.marko | 241 +++++ .../tags/cotyledon-main-readme.marko | 44 + .../tags/cotyledon-speedbump.marko | 52 + src/file-viewer/tags/media-dir.marko | 154 +++ src/file-viewer/tags/media-list-item.marko | 74 ++ src/file-viewer/tags/media-panel.marko | 165 ++++ src/file-viewer/tags/media-root-dir.marko | 76 ++ src/file-viewer/tags/view-audio.marko | 44 + src/file-viewer/tags/view-chat.marko | 87 ++ src/file-viewer/tags/view-code.marko | 18 + .../tags/view-download-not-computed.marko | 18 + .../tags/view-download-too-big.marko | 19 + src/file-viewer/tags/view-download.marko | 61 ++ src/file-viewer/tags/view-iframe.marko | 10 + src/file-viewer/tags/view-image.marko | 12 + src/file-viewer/tags/view-readme.marko | 15 + src/file-viewer/tags/view-text.marko | 162 +++ src/file-viewer/tags/view-video.marko | 26 + src/file-viewer/theme.ts | 5 + src/file-viewer/views/clofi.css | 8 +- src/file-viewer/views/clofi.marko | 39 + src/file-viewer/views/clofi.tsx | 933 ------------------ src/file-viewer/views/lofi.marko | 7 +- src/q+a/tags/question.marko | 3 +- src/tags/clover-video.css | 1 + src/tags/image.tsx | 62 -- 45 files changed, 1474 insertions(+), 1763 deletions(-) create mode 100644 lib/arrays.ts create mode 100644 lib/math.ts rename src/file-viewer/{backend.tsx => backend.ts} (91%) rename src/file-viewer/{views => }/clofi.client.ts (99%) delete mode 100644 src/file-viewer/cotyledon.tsx create mode 100644 src/file-viewer/pages/file.404.marko delete mode 100644 src/file-viewer/pages/file.404.tsx create mode 100644 src/file-viewer/pages/file.cotyledon-enterance.marko delete mode 100644 src/file-viewer/pages/file.cotyledon-enterance.tsx create mode 100644 src/file-viewer/pages/file.cotyledon-speedbump.marko delete mode 100644 src/file-viewer/pages/file.cotyledon-speedbump.tsx create mode 100644 src/file-viewer/tags/cotyledon-ending.marko create mode 100644 src/file-viewer/tags/cotyledon-main-readme.marko create mode 100644 src/file-viewer/tags/cotyledon-speedbump.marko create mode 100644 src/file-viewer/tags/media-dir.marko create mode 100644 src/file-viewer/tags/media-list-item.marko create mode 100644 src/file-viewer/tags/media-panel.marko create mode 100644 src/file-viewer/tags/media-root-dir.marko create mode 100644 src/file-viewer/tags/view-audio.marko create mode 100644 src/file-viewer/tags/view-chat.marko create mode 100644 src/file-viewer/tags/view-code.marko create mode 100644 src/file-viewer/tags/view-download-not-computed.marko create mode 100644 src/file-viewer/tags/view-download-too-big.marko create mode 100644 src/file-viewer/tags/view-download.marko create mode 100644 src/file-viewer/tags/view-iframe.marko create mode 100644 src/file-viewer/tags/view-image.marko create mode 100644 src/file-viewer/tags/view-readme.marko create mode 100644 src/file-viewer/tags/view-text.marko create mode 100644 src/file-viewer/tags/view-video.marko create mode 100644 src/file-viewer/theme.ts create mode 100644 src/file-viewer/views/clofi.marko delete mode 100644 src/file-viewer/views/clofi.tsx delete mode 100644 src/tags/image.tsx diff --git a/framework/incremental.ts b/framework/incremental.ts index ef96ecdebcf1ab9c96380b20810fc9aee6c1f170..f4914bb13602c9f297f2d866415b1e64e6561662 100644 --- a/framework/incremental.ts +++ b/framework/incremental.ts @@ -179,8 +179,8 @@ export async function flush(progress: progress.Node, start: number) { `sitegen! in ${time} ms\n` + ` - ${works.size} keys (${works.size - newKeys} cached)\n` + ` - ${assets.size} static assets\n` - + ` - dist: ${formatSize(dist)},` - + ` incremental: ${formatSize(serialized.byteLength)}`, + + ` - dist: ${string.formatByteSize(dist)},` + + ` incremental: ${string.formatByteSize(serialized.byteLength)}`, ); } @@ -730,9 +730,9 @@ import * as path from "node:path"; import * as util from "node:util"; import * as zlib from "node:zlib"; -import { formatSize } from "#src/file-viewer/format.ts"; import * as stack from "@clo/lib/log/stack"; import * as progress from "@clo/lib/progress"; +import * as string from "@clo/lib/string"; import { ASSERT, UNWRAP } from "@clo/lib/assert"; import * as msgpackr from "msgpackr"; diff --git a/framework/lib/add-script.marko b/framework/lib/add-script.marko index 0a67b29542f17c26506228e095e4de2aac0bc5bb..9b6213969acf8c51a03e45c65b37c6be6cf20a02 100644 --- a/framework/lib/add-script.marko +++ b/framework/lib/add-script.marko @@ -1 +1 @@ - + diff --git a/lib/arrays.ts b/lib/arrays.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5d0220baea98e01750ff6c77f5e105d3b7c7a49 --- /dev/null +++ b/lib/arrays.ts @@ -0,0 +1,20 @@ +/** + * Helper to iterate over multiple arrays at the same time, or to iterate over + * an array with an index, or both. + */ +export function* enumerate( + ...arrays: { [K in keyof T]: ArrayLike } +): Generator<[...items: T, index: number]> { + const { length } = arrays[0]; + arrays.forEach((x) => assert(x.length === length), "All arrays must be equal in length"); + for (let i = 0; i < length; i += 1) yield arrays.map((x) => x[i]!).concat(i) as [...T, number]; +} + +export function mapMany( + arrays: { [K in keyof T]: ArrayLike }, + map: (...args: [...items: T, index: number]) => O, +): O[] { + const { length } = arrays[0]; + arrays.forEach((x) => assert(x.length === length), "All arrays must be equal in length"); + return Array.from({ length }, (_, i) => map(...(arrays.map((a) => a[i]!).concat(i) as [...T, number]))); +} diff --git a/lib/math.ts b/lib/math.ts new file mode 100644 index 0000000000000000000000000000000000000000..11509965e9433eb10c9da0c4546d89567be9f852 --- /dev/null +++ b/lib/math.ts @@ -0,0 +1,8 @@ +export function gcd(a: number, b: number) { + while (b !== 0) [a, b] = [b, a % b]; + return a; +} + +export function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} diff --git a/lib/string.ts b/lib/string.ts index 1759fd0bd466a28da88fe4a8e239ab5b973c8dc0..9d41abc472bcfc268283d24b8138dd03f3a6be1f 100644 --- a/lib/string.ts +++ b/lib/string.ts @@ -13,17 +13,31 @@ export function countNewlines(str: string): number { return count; } -/** Escape a string for use in a regular expression. */ -export function escapeRegExp(unsafeText: string): string { - // TODO: in May 2025, RegExp.escape exists - return unsafeText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} +/** + * escape a string for use in a regular expression. + * @deprecated use `RegExp.escape` for browsers past May 2025 + */ +export const escapeRegExp: (unsafeText: string) => string = RegExp.escape + ?? ((unsafeText: string) => unsafeText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); -/** Escape a string for use in a regular expression. */ +/** escape a string for use in a regular expression. */ export function escapeRegExpReplacement(unsafeText: string): string { return unsafeText.replace(/\$/g, "$$$$"); } +export function escapeHtmlContent(unsafeText: string): string { + return unsafeText + .replace(/&/g, "&") + .replace(//g, ">"); +} + +export function escapeHtmlAttr(unsafeText: string): string { + return escapeHtmlContent(unsafeText) + .replace(/'/g, "'") + .replace(/"/g, """); +} + /** * Escape a string for use in a POSIX shell. Prefers easier to read output vs * simpler implementation. diff --git a/src/backend.ts b/src/backend.ts index ec51830aa8e3de98a48721977af7430b240a0307..7a9f6a6ad80ca1d829ecae450a1d716b525234ec 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -11,7 +11,7 @@ app.use(admin.middleware); // Backends app.route("", require("./blog/backend.ts").app); app.route("", require("./q+a/backend.ts").app); -app.route("", require("./file-viewer/backend.tsx").app); +app.route("", require("./file-viewer/backend.ts").app); app.route("", require("./friend-auth.ts").app); app.route("", require("./backend-extra.ts").app); diff --git a/src/file-viewer/backend.tsx b/src/file-viewer/backend.ts similarity index 91% rename from src/file-viewer/backend.tsx rename to src/file-viewer/backend.ts index 0cf5e7db32f014a30138530606746d5e23cdd033..f1844d651f884892ce690847fa1048a983a8bc52 100644 --- a/src/file-viewer/backend.tsx +++ b/src/file-viewer/backend.ts @@ -237,11 +237,7 @@ function isCotyledonPath(path: string) { function getPartialPage(c: Context, rawFilePath: string) { if (isCotyledonPath(rawFilePath)) { if (!checkCotyledonCookie(c)) { - let root = Speedbump(); - // Remove the root element, it's created client side! - root = root[2].content as render.Element; - - const html = render.sync(root).text; + const html = cotyledonSpeedbump.render().toString(); c.header("X-Cotyledon", "true"); return c.html(html); } @@ -260,20 +256,14 @@ function getPartialPage(c: Context, rawFilePath: string) { return c.json({ error: "File not found" }, 404); } - const r = render.init(false, []); - let root = render.resolveNode( - r, - , - ); - // Remove the root element, it's created client side! - root = (root as render.ResolvedElement)[3]; - - return c.html(render.stringifyNode(root)); + const html = mediaPanel.render({ + file, + isLast: true, + activeFilename: null, + hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c), + noWrapper: true, + }).toString(); + return c.html(html); } import { type Context, Hono } from "hono"; @@ -282,14 +272,14 @@ import { etagMatches, serveAsset } from "#sitegen/assets"; import * as view from "#sitegen/view"; import * as http from "@clo/lib/http"; import * as mime from "@clo/lib/mime"; -import * as render from "@clo/lib/render"; + +import cotyledonSpeedbump from "./tags/cotyledon-speedbump.marko"; +import mediaPanel from "./tags/media-panel.marko"; import * as cache from "#src/file-viewer/cache.ts"; -import { Speedbump } from "#src/file-viewer/cotyledon.tsx"; import * as derived from "#src/file-viewer/models/derived.ts"; import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts"; import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts"; -import { MediaPanel } from "#src/file-viewer/views/clofi.tsx"; import { getForFile, requireFriendAuth } from "#src/friend-auth.ts"; import { UNWRAP } from "@clo/lib/assert"; import { escapeUri } from "./format.ts"; diff --git a/src/file-viewer/bin/file-scan.ts b/src/file-viewer/bin/file-scan.ts index 7ff63f949ce37d7a9664920c9c2cd0211b8fda00..57fdf6509d3c395df2182b5a58f1bbb5bd836744 100644 --- a/src/file-viewer/bin/file-scan.ts +++ b/src/file-viewer/bin/file-scan.ts @@ -437,9 +437,9 @@ export async function main() { "Global Stats:\n" + `- File Count: \x1b[1m${count}\x1b[0m\n` + `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n` - + `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n` - + `- Canonical Size: \x1b[1m${formatSize(canonicalSize)}\x1b[0m\n` - + `- Derived Size: \x1b[1m${formatSize(derivedSize)}\x1b[0m\n`, + + `- Media Duration: \x1b[1m${string.formatDurationLetters(duration)}\x1b[0m\n` + + `- Canonical Size: \x1b[1m${string.formatByteSize(canonicalSize)}\x1b[0m\n` + + `- Derived Size: \x1b[1m${string.formatByteSize(derivedSize)}\x1b[0m\n`, ); } @@ -1022,10 +1022,12 @@ const monthMilliseconds = 30 * 24 * 60 * 60 * 1000; import * as fs from "#sitegen/fs"; import { Path } from "#sitegen/path"; + import * as async from "@clo/lib/async"; import * as log from "@clo/lib/log"; import * as progress from "@clo/lib/progress"; import * as queue from "@clo/lib/queue"; +import * as string from "@clo/lib/string"; import * as subprocess from "@clo/lib/subprocess"; import * as ts from "@clo/lib/ts"; @@ -1036,7 +1038,7 @@ import * as path from "node:path"; import * as stream from "node:stream"; import * as zlib from "node:zlib"; -import { formatDate, formatDurationLong, formatSize } from "#src/file-viewer/format.ts"; +import { formatDate } from "#src/file-viewer/format.ts"; import * as highlight from "#src/file-viewer/highlight.ts"; import * as derived from "#src/file-viewer/models/derived.ts"; import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts"; diff --git a/src/file-viewer/views/clofi.client.ts b/src/file-viewer/clofi.client.ts similarity index 99% rename from src/file-viewer/views/clofi.client.ts rename to src/file-viewer/clofi.client.ts index da9fd2fc15dac989c3060ada466b36f035a3551e..0b9351df8d3d0000f47319d9c67ba7158cf57089 100644 --- a/src/file-viewer/views/clofi.client.ts +++ b/src/file-viewer/clofi.client.ts @@ -204,6 +204,7 @@ class Panel { }); if (id === "cotyledon") { filesContainer.classList.add("ctld-sb"); + filesContainer.classList.add("ctld"); const group = document.querySelector( "[data-group='cotyledon']", )! as HTMLElement; @@ -556,9 +557,9 @@ async function navigate(pathname: string, pushState: boolean) { if (currentSplit[0] !== filePathSplit[0]) { if (currentSplit[0] === "cotyledon") { - filesContainer.classList.remove("ctld-et", "ctld-sb"); + filesContainer.classList.remove("ctld", "ctld-et", "ctld-sb"); } - if (parseInt(currentSplit[0]) < 2025) { + if (parseInt(currentSplit[0]) > 2025) { filesContainer.classList.remove("ctld", "ctld-" + currentSplit[0]); } if (parseInt(filePathSplit[0]) < 2025) { diff --git a/src/file-viewer/cotyledon.tsx b/src/file-viewer/cotyledon.tsx deleted file mode 100644 index d7b61f868b7c101f5a36162c12b1589349eed375..0000000000000000000000000000000000000000 --- a/src/file-viewer/cotyledon.tsx +++ /dev/null @@ -1,376 +0,0 @@ -// WARNING -// ------- -// This file contains spoilers for COTYLEDON -// Consider reading through the entire archive before picking apart this -// code, as this contains the beginning AND the ending sections, which -// contains very percise storytelling. You've been warned... -// -// --> https://paperclover.net/file/cotyledon <-- -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// - -// SPEEDBUMP -export function Speedbump() { - return ( -
-
- an interlude -
-
- - -
-

cotyledon

-
- -
-

- this place is sacred, but dangerous. i have to keep visitors to an absolute minimum; you'll get dust on all - the artifacts. -

-

- by entering our museum, you agree not to use your camera. flash off isn't enough; the bits and bytes are - alergic even to a camera's sensor -

-

- (in english: please do not store downloads after you're done viewing them) -

-
- -
-
-
-
- ); -} - -// OPENING -export function Readme() { - return ( -
-
- cotyledon -
-
-
-

- welcome to the archive. if this is your first time here, i recommend starting in ' - 2017 - ' and going chronologically from there. however, there is truly no wrong way to explore. -

-

- note that there is a blanket trigger warning for everything in this archive: while there is nothing visually - offensive, some portions of the text and emotions conveyed through this may hit extremely hard. you are - warned. -

-

- all file dates are real. at least as real as i could figure out. when i moved data across drives over my - years, i accidentally had a few points where i stamped over all the dates with the day that moved the files. - even fucked it up a final time in february 2025, while in the process of unfucking things. -

-

- thankfully, my past self knew i'd want to assemble this kind of site, and because of that they were crazy - about storing the dates of things inside of html, json/yaml files, and even in fucking databases. i'm glad - it was all stored though, but jeez what a nerd. -

-

- a few files were touched up for privacy, or otherwise re-encoded. some of them i added extra metadata. -

-

- from the bottom of my heart: i hope you enjoy. it has been a nightmare putting this all together. - technically and emotionally speaking. i'm glad we can put this all behind us, mark it as completed, and get - started with the good shit. -

-

- love,
clo -

-
-

- start here -> 2017 -

-
-
-
- ); -} - -// TRUE ENDING. Written in Apple Notes. -export function ForEveryone() { - return ( - <> -
-

today is my 21st birthday. april 30th, 2025.

-

it's been nearly six months starting hormones.

-

sometimes i feel great,

-

sometimes i get dysphoria.

-

with the walls around me gone

-

that shit hits way harder than it did before.

-

ugh..

-

i'm glad the pain i felt is now explained,

-

but now rendered in high definition.

-

the smallest strands of hair on my face and belly act

-

as sharpened nails to pierce my soul.

-

-

it's all a pathway to better days; the sun had risen.

-

one little step at a time for both of us.

-

today i quit my job. free falling, it feels so weird.

-

like sky diving.

-

the only thing i feel is cold wind.

-

the only thing i see is everything,

-

and it's beautiful.

-

i have a month of falling before the parachute activates,

-

gonna spend as much time of it on art as i can.

-

that was, after all, my life plan:

-

i wanted to make art, all the time,

-

for everyone.

-

-

then you see what happened

-

to the world and the internet.

-

i never really got to live through that golden age,

-

it probably sucked back then too.

-

but now the big sites definitely stopped being fun.

-

they slide their cold hands up my body

-

and feel me around. it's unwelcoming, and

-

inconsiderate to how sensitive my skin is.

-

i'm so fucking glad i broke up with YouTube

-

and their devilish friends.

-

my NAS is at 5 / 24 TB

-

and probably wont fill for the next decade.

-

-

it took 2 months for me to notice my body changed.

-

that day was really nice, but it hurt a lot.

-

a sharp, satisfying pain in my chest gave me life.

-

learned new instincts for my arms

-

so they'd stop poking my new shape.

-

when i look at my face

-

it's like a different person.

-

she was the same as before, but completely new.

-

something changed

-

or i'm now used to seeing what makes me smile.

-

regardless, whatever i see in the mirror, i smile.

-

and, i don't hear that old name much anymore

-

aside from nightmares. and you'll never repeat it, ok?

-

okay.

-

-

been playing 'new canaan' by 'bill wurtz' on loop

-

in the background.

-

it kinda just feels right.

-

especially when that verse near the end comes on.

-

-

more people have been allowed to visit me.

-

my apartment used to be just for me,

-

but the more i felt like a person

-

the more i felt like having others over.

-

still have to decorate and clean it a little,

-

but it isn't a job to do alone.

-

we dragged a giant a rug across the city one day,

-

and it felt was like anything was possible.

-

sometimes i have ten people visit in a day,

-

or sometimes i focus my little eyes on just one.

-

i never really know what i want to do

-

until the time actually comes.

-

- {/* FILIP */} -

i think about the times i was by the water with you.

-

the sun setting warmly, icy air fell on our shoulders.

- {/* NATALIE */} -

and how we walked up to the top of that hill,

-

you picked up and disposed a nail on the ground,

-

walking the city thru places i've never been.

- {/* BEN */} -

or hiking through the park talking about compilers,

-

tiring me out until i'd fall asleep in your arms.

- {/* ELENA */} -

and the way you held on to my hand as i woke up,

-

noticing how i was trying to hide nightmare's tears.

-

- {/* HIGH SCHOOL */} -

i remember we were yelling lyrics loudly,

-

out of key yet cheered on because it was fun.

- {/* ADVAITH/NATALIE */} -

and when we all toured the big corporate office,

- {/* AYU/HARRIS */} -

then snuck in to some startup's office after hours;

-

i don't remember what movie we watched.

- {/* COLLEGE, DAY 1 IN EV's ROOM */} -

i remember laying on the bunk bed,

-

while the rest played a card game.

- {/* MEGHAN/MORE */} -

with us all laying on the rug, staring at the TV

-

- as the ending twist to {/* SEVERANCE */ "that show"} was revealed. -

-

-

all the moments i cherish,

-

i love because it was always me.

-

i didn't have to pretend,

-

even if i didn't know who i was at the time.

-

you all were there. for me.

-

-

i don't want to pretend any more

-

i want to be myself. for everyone.

-

-

oh, the song ended. i thought it was on loop?

-

it's late... can hear the crickets...

-

and i can almost see the moon... mmmm...

-

...nah, too much light pollution.

-

-

one day. one day.

-

-

- before i go, i want to show the uncensored version of "journal about a girl", because i can trust you at - least. keep in mind, i think you're one of the first people to ever see this. -

-
-
-
-

journal - 2024-09-14

-

- been at HackMIT today on behalf of the company. it's fun. me and zack were running around looking for people - that might be good hires. he had this magic arbitrary criteria to tell "oh this person is probably cracked - let's talk to them" and we go to the first one. they were a nerd, perfect. they seemed to be extremely - talented with some extreme software projects.
- okay.. oof... its still clouding my mind
- i cant shake that feeling away -

-

hold on...

-

- at some point they open one of their profiles to navigate to some code, and it displays for a couple of - seconds: "pronouns: she/they". i don't actually know anything about this person, but it was my perception - that she is trans. their appearance, physique, and age felt similar to me, which tends makes people think - you are male. -

-

- but... she was having fun being herself. being a legend of identity and of her skill in computer science. - winning the physics major. making cool shit at the hackathon, and probably in life. my perception of her was - the exact essence of who i myself wanted to be. i was jealous of her life. -

-

- i tried hard to avoid a breakdown. success. but i was feeling distant. the next hour or so was disorienting, - trying not to think about it too hard. i think there was one possibly interesting person we talked to. i - don't remember any of the other conversations. they were not important. but i couldn't think through them - regardless. -

-

- later, i decided to read some of her code. i either have a huge dislike towards the Rust programming - language and/or it was not high quality code. welp, so just is a person studying. my perception was just a - perception, inaccurate but impacting. i know i need to become myself, whoever that is. otherwise, i'm just - going to feel this shit at higher doses. i think about this every day, and the amount of time i feel being - consumed by these problems only grows. -

-

- getting through it all is a lonely feeling. not because no one is around, but because i am isolated - emotionally. i know other people hit these feelings, but we all are too afraid to speak up, and it's all - lonely. -

-

- waiting on a reply from someone from healthcare. it'll be slow, but it will be okay. -

-
-
-
-

- i've learned that even when i feel alone, it doesn't have to feel lonely. i know it's hard, dear. i know it's - scary. but i promise it's possible. we're all in this together. struggling together. sacrificing together. we - dedicate our lives to each you, and our art for everyone. -

- -

- and then we knew,
- just like paper airplanes: that we could fly... -

-
-

- - fin. - -

-
- - ); -} -ForEveryone.class = "text"; diff --git a/src/file-viewer/format.ts b/src/file-viewer/format.ts index dd036560d344da673105606ab112b835340466c4..58619823417ded50dc6ca5a2d75502468c9c17bc 100644 --- a/src/file-viewer/format.ts +++ b/src/file-viewer/format.ts @@ -1,24 +1,3 @@ -const findDomain = "paperclover.net"; - -export function formatSize(bytes: number) { - if (bytes < 1024) return `${bytes} bytes`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) { - return `${(bytes / 1024 / 1024).toFixed(1)} MB`; - } - return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; -} - -// export function formatDateDefined(date: Date) { -// // YYYY-MM-DD, format in PST timezone -// return date.toLocaleDateString("sv", { timeZone: "America/Los_Angeles" }); -// } -// -// export function formatShortDate(date: Date) { -// // YY-MM-DD, format in PST timezone -// return formatDate(date).slice(2); -// } - export function formatDuration(seconds: number) { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; @@ -44,229 +23,6 @@ export function escapeUri(uri: string) { .replace(/%2C/gi, ","); } -// Returns escaped HTML -// Features: -// - autolink detection -// - via \bpaperclover.net/[a-zA-Z0-9_\.+-]+ -// - via \b/file/[a-zA-Z0-9_\.+-]+ -// - via \bhttps://... -// - via name of a sibling file's basename -// - reformat (c) into © -// -// This formatter was written with AI. Then manually fixed since AI does not work. -export function highlightLinksInTextView( - text: string, - siblingFiles: MediaFile[] = [], -) { - const siblingLookup = Object.fromEntries( - siblingFiles - .filter((f) => f.basename !== "readme.txt") - .map((f) => [f.basename, f]), - ); - - // First escape the HTML to prevent XSS - let processedText = escapeHtml(text); - - // Replace (c) with © - processedText = processedText.replace(/\(c\)/gi, "©"); - - // Process all URL patterns in a single pass to avoid nested links - // This regex matches: - // 1. https:// or http:// URLs - // 2. domain URLs without protocol (e.g., paperclover.net/path) - // 3. /file/ URLs - // 4. ./ relative paths - - // We'll use a function to determine what kind of URL it is and format accordingly - const urlRegex = new RegExp( - "(" - // Group 1: https:// or http:// URLs - + "\\bhttps?:\\/\\/[a-zA-Z0-9_\\.\\-]+\\.[a-zA-Z0-9_\\.\\-]+[a-zA-Z0-9_\\.\\-\\/\\?=&%+#]*" - + "|" - // Group 2: domain URLs without protocol - + findDomain - + "\\/\\/[a-zA-Z0-9_\\.\\+\\-]+" - + "|" - // Group 3: /file/ URLs - + "\\/file\\/[a-zA-Z0-9_\\.\\+\\-\\/]+" - + ")\\b" - + "|" - // Group 4: ./ relative paths (not word-bounded) - + "(?<=\\s|^)\\.\\/[\\w\\-\\.]+", - "g", - ); - - processedText = processedText.replace(urlRegex, (match: string) => { - // Case 1: https:// or http:// URLs - if (match.startsWith("http")) { - if (match.includes(findDomain)) { - return `${match}`; - } - return `${match}`; - } - - // Case 2: domain URLs without protocol - if (match.startsWith(findDomain)) { - return `${match}`; - } - - // Case 3: /file/ URLs - if (match.startsWith("/file/")) { - return `${match}`; - } - - // Case 4: ./ relative paths - if (match.startsWith("./")) { - const filename = match.substring(2); - const siblingFile = siblingFiles.find((f) => f.basename === filename); - if (siblingFile) { - return `${match}`; - } - - if (siblingFiles.length > 0) { - const currentDir = siblingFiles[0].path - .split("/") - .slice(0, -1) - .join("/"); - return `${match}`; - } - } - - return match; - }); - - // Match sibling file names (only if they're not already part of a link) - if (siblingFiles.length > 0) { - const escapedBasenames = siblingFiles.map((f) => f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); - const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g"); - const parts = processedText.split(/(<[^>]*>)/); - - for (let i = 0; i < parts.length; i += 2) { - if (i < parts.length) { - parts[i] = parts[i].replace(pattern, (match: string) => { - const file = siblingLookup[match]; - if (file) { - return `${match}`; - } - return match; - }); - } - } - - processedText = parts.join(""); - } - - return processedText; -} - -export function highlightConvo(text: string) { - text = text.replace(/^#mode=convo\n/, ""); - - const lines = text.split("\n"); - const paras: { speaker: string | null; lines: string[] }[] = []; - let currentPara: string[] = []; - let currentSpeaker: string | null = null; - let firstSpeaker = null; - - const speakers: Record = {}; - const getSpeaker = (s: string) => { - if (s[1] === " " && speakers[s[0]]) { - return s[0]; - } - return null; - }; - - for (const line of lines) { - let trimmed = line.trim(); - if (line.startsWith("#")) { - // parse #X=Y - const [_, speaker, color] = trimmed.match(/^#(.)=(.*)$/)!; - speakers[speaker] = color; - continue; - } - if (trimmed === "") { - continue; - } - let speaker = getSpeaker(trimmed); - if (speaker) { - trimmed = trimmed.substring(speaker.length).trimStart(); - speaker = speakers[speaker]; - } else { - speaker = "me"; - } - - trimmed = trimmed.replace( - /\[IMG:(\/file\/[^\]]+)\]/g, - "\"attachment\"", - ); - - if (trimmed === "---" && speaker === "me") { - trimmed = "
"; - } - - if (speaker === currentSpeaker) { - currentPara.push(trimmed); - } else { - if (currentPara.length > 0) { - paras.push({ - speaker: currentSpeaker, - lines: currentPara, - }); - currentPara = []; - } - currentPara = [trimmed]; - currentSpeaker = speaker; - firstSpeaker ??= speaker; - } - } - - if (currentPara.length > 0) { - paras.push({ - speaker: currentSpeaker, - lines: currentPara, - }); - } - - return paras - .map(({ speaker, lines }) => { - return `
${ - lines - .map((line) => `
${line}
`) - .join("\n") - }
`; - }) - .join("\n"); -} - -export function highlightHashComments(text: string) { - const lines = text.split("\n"); - return lines - .map((line) => { - if (line.startsWith("#")) { - return `
${line}
`; - } - return `
${line.trimEnd() || " "}
`; - }) - .join("\n"); -} - const unknownDate = new Date("1970-01-03"); const unknownDateWithKnownYear = new Date("1970-02-20"); @@ -282,6 +38,3 @@ export function formatDate(dateTime: Date) { .padStart(2, "0") }.${dateTime.getFullYear().toString().slice(2)}`; } - -import type { MediaFile } from "#src/file-viewer/models/MediaFile.ts"; -import { escapeHtml } from "@clo/lib/render"; diff --git a/src/file-viewer/highlight.ts b/src/file-viewer/highlight.ts index 248265703bae931179e136e526d5536468195f0e..050f156624bc6ad59c05b23893bc70641684a502 100644 --- a/src/file-viewer/highlight.ts +++ b/src/file-viewer/highlight.ts @@ -113,21 +113,19 @@ function highlightLines({ }: HighlightLinesOptions) { let html = ""; let lastHtmlStyle: string | null = null; - - const { length } = lines; - for (let i = 0; i < length; i += 1) { + for (const line of lines) { const { tokens, ruleStack, stoppedEarly } = grammar.tokenizeLine( - lines[i], + line, state, ); if (stoppedEarly) throw new Error("TODO: Tokenization stopped early?"); state = ruleStack; for (const token of tokens) { - const str = lines[i].slice(token.startIndex, token.endIndex); + const str = line.slice(token.startIndex, token.endIndex); if (str.trim().length === 0) { // Emit but do not consider scope changes - html += render.escapeHtml(str); + html += string.escapeHtmlContent(str); continue; } @@ -136,7 +134,7 @@ function highlightLines({ if (lastHtmlStyle) html += ""; if (style) html += ``; } - html += render.escapeHtml(str); + html += string.escapeHtmlContent(str); lastHtmlStyle = style; } html += "\n"; @@ -217,7 +215,7 @@ export async function highlightCode(code: string, language: Language) { // import * as fs from "#sitegen/fs"; import * as async from "@clo/lib/async"; -import * as render from "@clo/lib/render"; +import * as string from "@clo/lib/string"; import * as path from "node:path"; import * as oniguruma from "vscode-oniguruma"; import * as textmate from "vscode-textmate"; diff --git a/src/file-viewer/pages/file.404.marko b/src/file-viewer/pages/file.404.marko new file mode 100644 index 0000000000000000000000000000000000000000..2ba3f40d556522debdfcfded749b72a2fce3526e --- /dev/null +++ b/src/file-viewer/pages/file.404.marko @@ -0,0 +1,23 @@ +export const meta = { title: "file not found" }; +export { theme } from "../theme.ts"; + +client import "../scripts/canvas_cotyledon.client.ts"; + +
+ +
+
+
+

this file does not exist ...

+

return

+
+
+
+ +import { UNWRAP } from "@clo/lib/assert"; +import { MediaFile } from "../models/MediaFile.ts"; diff --git a/src/file-viewer/pages/file.404.tsx b/src/file-viewer/pages/file.404.tsx deleted file mode 100644 index a97d1a07cd88e5bb715618ae5a4cc7a2a4da59d8..0000000000000000000000000000000000000000 --- a/src/file-viewer/pages/file.404.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { addScript } from "#sitegen"; -import { MediaFile } from "../models/MediaFile.ts"; -import { MediaPanel } from "../views/clofi.tsx"; - -export const theme = { - bg: "#312652", - fg: "#f0f0ff", - primary: "#fabe32", -}; - -export const meta = { title: "file not found" }; - -export default function CotyledonPage() { - addScript("../scripts/canvas_cotyledon.client.ts"); - return ( -
- -
-
-
-

this file does not exist ...

-

- return -

-
-
-
- ); -} diff --git a/src/file-viewer/pages/file.cotyledon-enterance.marko b/src/file-viewer/pages/file.cotyledon-enterance.marko new file mode 100644 index 0000000000000000000000000000000000000000..8ea6e444a41ce41accd2cbb13f1804a30a1067e0 --- /dev/null +++ b/src/file-viewer/pages/file.cotyledon-enterance.marko @@ -0,0 +1,17 @@ +export const meta = { title: "file not found" }; +export { theme } from "../theme.ts"; + +client import "../scripts/canvas_cotyledon.client.ts"; + +
+ + +
+ +import { UNWRAP } from "@clo/lib/assert"; +import { MediaFile } from "../models/MediaFile.ts"; diff --git a/src/file-viewer/pages/file.cotyledon-enterance.tsx b/src/file-viewer/pages/file.cotyledon-enterance.tsx deleted file mode 100644 index 4b3490fe7f78a120bf24f7cd18fc02a316f26e2c..0000000000000000000000000000000000000000 --- a/src/file-viewer/pages/file.cotyledon-enterance.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { addScript } from "#sitegen"; -import { Readme } from "#src/file-viewer/cotyledon.tsx"; -import { MediaFile } from "#src/file-viewer/models/MediaFile.ts"; -import { MediaPanel } from "../views/clofi.tsx"; - -export const theme = { - bg: "#312652", - fg: "#f0f0ff", - primary: "#fabe32", -}; - -export const meta = { title: "living room" }; - -export default function CotyledonPage() { - addScript("../scripts/canvas_cotyledon.client.ts"); - return ( -
- - -
- ); -} diff --git a/src/file-viewer/pages/file.cotyledon-speedbump.marko b/src/file-viewer/pages/file.cotyledon-speedbump.marko new file mode 100644 index 0000000000000000000000000000000000000000..e1d64b772e4740d891042e6dc9f71d1966ced475 --- /dev/null +++ b/src/file-viewer/pages/file.cotyledon-speedbump.marko @@ -0,0 +1,19 @@ +export const meta = { title: "file not found" }; +export { theme } from "../theme.ts"; + +client import "../scripts/canvas_cotyledon.client.ts"; + +
+ +
+ +
+
+ +import { UNWRAP } from "@clo/lib/assert"; +import { MediaFile } from "../models/MediaFile.ts"; diff --git a/src/file-viewer/pages/file.cotyledon-speedbump.tsx b/src/file-viewer/pages/file.cotyledon-speedbump.tsx deleted file mode 100644 index 3a89609321524ecc5944a7f6c18a0554efb54719..0000000000000000000000000000000000000000 --- a/src/file-viewer/pages/file.cotyledon-speedbump.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { addScript } from "#sitegen"; -import { Speedbump } from "../cotyledon.tsx"; -import { MediaFile } from "../models/MediaFile.ts"; -import { MediaPanel } from "../views/clofi.tsx"; - -export const theme = { - bg: "#312652", - fg: "#f0f0ff", - primary: "#fabe32", -}; - -export const meta = { title: "the front door" }; - -export default function CotyledonPage() { - addScript("../scripts/canvas_cotyledon.client.ts"); - return ( -
- - -
- ); -} diff --git a/src/file-viewer/rsync.ts b/src/file-viewer/rsync.ts index 76da5357cc97054306aab4c4091ce985f83a81d8..1d9d61077ff7ce14e1734fa426f620368d4c08bd 100644 --- a/src/file-viewer/rsync.ts +++ b/src/file-viewer/rsync.ts @@ -65,7 +65,7 @@ export async function spawn(options: SpawnOptions) { node.value = transferNumber; node.total = totalFiles; const extras = [ - formatSize(bytesTransferred), + string.formatByteSize(bytesTransferred), (totalFiles > filesToCheck) ? `${totalFiles - filesToCheck} unchecked` : null, @@ -179,9 +179,9 @@ export class Parse { } import { Path } from "#sitegen/path"; -import { formatSize } from "#src/file-viewer/format.ts"; import { UNWRAP } from "@clo/lib/assert"; import * as progress from "@clo/lib/progress"; +import * as string from "@clo/lib/string"; import * as child_process from "node:child_process"; import events from "node:events"; import * as readline from "node:readline"; diff --git a/src/file-viewer/tags/cotyledon-ending.marko b/src/file-viewer/tags/cotyledon-ending.marko new file mode 100644 index 0000000000000000000000000000000000000000..38f744baed38e6558ac00f3aab927a80f1ae2937 --- /dev/null +++ b/src/file-viewer/tags/cotyledon-ending.marko @@ -0,0 +1,241 @@ +// WARNING +// ------- +// This file contains spoilers for COTYLEDON +// Consider reading through the entire archive before picking +// apart this code, as this contains the ending sections, which +// contains very percise storytelling. You've been warned... +// +// --> https://paperclover.net/file/cotyledon <-- +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// + +// Written in Apple Notes. +
+

today is my 21st birthday. april 30th, 2025.

+

it's been nearly six months starting hormones.

+

sometimes i feel great,

+

sometimes i get dysphoria.

+

with the walls around me gone

+

that shit hits way harder than it did before.

+

ugh..

+

i'm glad the pain i felt is now explained,

+

but now rendered in high definition.

+

the smallest strands of hair on my face and belly act

+

as sharpened nails to pierce my soul.

+

+

it's all a pathway to better days; the sun had risen.

+

one little step at a time for both of us.

+

today i quit my job. free falling, it feels so weird.

+

like sky diving.

+

the only thing i feel is cold wind.

+

the only thing i see is everything,

+

and it's beautiful.

+

i have a month of falling before the parachute activates,

+

gonna spend as much time of it on art as i can.

+

that was, after all, my life plan:

+

i wanted to make art, all the time,

+

for everyone.

+

+

then you see what happened

+

to the world and the internet.

+

i never really got to live through that golden age,

+

it probably sucked back then too.

+

but now the big sites definitely stopped being fun.

+

they slide their cold hands up my body

+

and feel me around. it's unwelcoming, and

+

inconsiderate to how sensitive my skin is.

+

i'm so fucking glad i broke up with YouTube

+

and their devilish friends.

+

my NAS is at 5 / 24 TB

+

and probably wont fill for the next decade.

+

+

it took 2 months for me to notice my body changed.

+

that day was really nice, but it hurt a lot.

+

a sharp, satisfying pain in my chest gave me life.

+

learned new instincts for my arms

+

so they'd stop poking my new shape.

+

when i look at my face

+

it's like a different person.

+

she was the same as before, but completely new.

+

something changed

+

or i'm now used to seeing what makes me smile.

+

regardless, whatever i see in the mirror, i smile.

+

and, i don't hear that old name much anymore

+

aside from nightmares. and you'll never repeat it, ok?

+

okay.

+

+

been playing 'new canaan' by 'bill wurtz' on loop

+

in the background.

+

it kinda just feels right.

+

especially when that verse near the end comes on.

+

+

more people have been allowed to visit me.

+

my apartment used to be just for me,

+

but the more i felt like a person

+

the more i felt like having others over.

+

still have to decorate and clean it a little,

+

but it isn't a job to do alone.

+

we dragged a giant a rug across the city one day,

+

and it felt was like anything was possible.

+

sometimes i have ten people visit in a day,

+

or sometimes i focus my little eyes on just one.

+

i never really know what i want to do

+

until the time actually comes.

+

+ {/* FILIP */} +

i think about the times i was by the water with you.

+

the sun setting warmly, icy air fell on our shoulders.

+ {/* NATALIE */} +

and how we walked up to the top of that hill,

+

you picked up and disposed a nail on the ground,

+

walking the city thru places i've never been.

+ {/* BEN */} +

or hiking through the park talking about compilers,

+

tiring me out until i'd fall asleep in your arms.

+ {/* ELENA */} +

and the way you held on to my hand as i woke up,

+

noticing how i was trying to hide nightmare's tears.

+

+ {/* HIGH SCHOOL */} +

i remember we were yelling lyrics loudly,

+

out of key yet cheered on because it was fun.

+ {/* ADVAITH/NATALIE */} +

and when we all toured the big corporate office,

+ {/* AYU/HARRIS */} +

then snuck in to some startup's office after hours;

+

i don't remember what movie we watched.

+ {/* COLLEGE, DAY 1 IN EV's ROOM */} +

i remember laying on the bunk bed,

+

while the rest played a card game.

+ {/* MEGHAN/MORE */} +

with us all laying on the rug, staring at the TV

+

as the ending twist to {/* SEVERANCE */ "that show"} was revealed.

+

+

all the moments i cherish,

+

i love because it was always me.

+

i didn't have to pretend,

+

even if i didn't know who i was at the time.

+

you all were there. for me.

+

+

i don't want to pretend any more

+

i want to be myself. for everyone.

+

+

oh, the song ended. i thought it was on loop?

+

it's late... can hear the crickets...

+

and i can almost see the moon... mmmm...

+

...nah, too much light pollution.

+

+

one day. one day.

+

+

+ before i go, i want to show the uncensored version of "journal about a + girl", because i can trust you at least. keep in mind, i think you're one of + the first people to ever see this. +

+
+
+
+

journal - 2024-09-14

+

+ been at HackMIT today on behalf of the company. it's fun. me and zack were + running around looking for people that might be good hires. he had this + magic arbitrary criteria to tell "oh this person is probably cracked let's + talk to them" and we go to the first one. they were a nerd, perfect. they + seemed to be extremely talented with some extreme software projects.
okay.. + oof... its still clouding my mind
i cant shake that feeling away +

+

hold on...

+

+ at some point they open one of their profiles to navigate to some code, + and it displays for a couple of seconds: "pronouns: she/they". i don't + actually know anything about this person, but it was my perception that + she is trans. their appearance, physique, and age felt similar to me, + which tends makes people think you are male. +

+

+ but... she was having fun being herself. being a legend of identity and of + her skill in computer science. winning the physics major. making cool shit + at the hackathon, and probably in life. my perception of her was the exact + essence of who i myself wanted to be. i was jealous of her life. +

+

+ i tried hard to avoid a breakdown. success. but i was feeling distant. the + next hour or so was disorienting, trying not to think about it too hard. i + think there was one possibly interesting person we talked to. i don't + remember any of the other conversations. they were not important. but i + couldn't think through them regardless. +

+

+ later, i decided to read some of her code. i either have a huge dislike + towards the Rust programming language and/or it was not high quality code. + welp, so just is a person studying. my perception was just a perception, + inaccurate but impacting. i know i need to become myself, whoever that is. + otherwise, i'm just going to feel this shit at higher doses. i think about + this every day, and the amount of time i feel being consumed by these + problems only grows. +

+

+ getting through it all is a lonely feeling. not because no one is around, + but because i am isolated emotionally. i know other people hit these + feelings, but we all are too afraid to speak up, and it's all lonely. +

+

+ waiting on a reply from someone from healthcare. it'll be slow, but it + will be okay. +

+
+
+
+

+ i've learned that even when i feel alone, it doesn't have to feel lonely. i + know it's hard, dear. i know it's scary. but i promise it's possible. we're + all in this together. struggling together. sacrificing together. we dedicate + our lives to each you, and our art for everyone. +

+ +

+ and then we knew,
just like paper airplanes: that we could fly... +

+
+

+ + fin. + +

+
diff --git a/src/file-viewer/tags/cotyledon-main-readme.marko b/src/file-viewer/tags/cotyledon-main-readme.marko new file mode 100644 index 0000000000000000000000000000000000000000..448669b25eed18af3cf1ab69154f863c779e7bf3 --- /dev/null +++ b/src/file-viewer/tags/cotyledon-main-readme.marko @@ -0,0 +1,44 @@ +
+
cotyledon
+
+
+

+ welcome to the archive. if this is your first time here, i recommend + starting in '2017' and going chronologically + from there. however, there is truly no wrong way to explore. +

+

+ note that there is a blanket trigger warning for everything in this + archive: while there is nothing visually offensive, some portions of the + text and emotions conveyed through this may hit extremely hard. you are + warned. +

+

+ all file dates are real. at least as real as i could figure out. when i + moved data across drives over my years, i accidentally had a few points + where i stamped over all the dates with the day that moved the files. + even fucked it up a final time in february 2025, while in the process of + unfucking things. +

+

+ thankfully, my past self knew i'd want to assemble this kind of site, + and because of that they were crazy about storing the dates of things + inside of html, json/yaml files, and even in fucking databases. i'm glad + it was all stored though, but jeez what a nerd. +

+

+ a few files were touched up for privacy, or otherwise re-encoded. some + of them i added extra metadata. +

+

+ from the bottom of my heart: i hope you enjoy. it has been a nightmare + putting this all together. technically and emotionally speaking. i'm + glad we can put this all behind us, mark it as completed, and get + started with the good shit. +

+

love,
clo

+
+

start here -> 2017

+
+
+
diff --git a/src/file-viewer/tags/cotyledon-speedbump.marko b/src/file-viewer/tags/cotyledon-speedbump.marko new file mode 100644 index 0000000000000000000000000000000000000000..56e561cfde1a8dc2ec1665a2613f3d2bf3afd9a3 --- /dev/null +++ b/src/file-viewer/tags/cotyledon-speedbump.marko @@ -0,0 +1,52 @@ +
an interlude
+
+ +
+

cotyledon

+
+ +
+

+ this place is sacred, but dangerous. i have to keep visitors to an + absolute minimum; you'll get dust on all the artifacts. +

+

+ by entering our museum, you agree not to use your camera. flash off isn't + enough; the bits and bytes are alergic even to a camera's sensor +

+

+ (in english: please do not store downloads after you're done viewing them) +

+
+ +
+
+
diff --git a/src/file-viewer/tags/media-dir.marko b/src/file-viewer/tags/media-dir.marko new file mode 100644 index 0000000000000000000000000000000000000000..a50ec5f24c66e13a77e8ffab8a359a49860d66ad --- /dev/null +++ b/src/file-viewer/tags/media-dir.marko @@ -0,0 +1,154 @@ +export interface Input { + dir: MediaFile; + activeFilename: string | null; + isLast: boolean; + hasCotyledonCookie: boolean; +} + +static interface DirConfig { + hideChildrenDates?: boolean; +} + + + { + let { activeFilename } = input; + const isCotyledon = parseInt(UNWRAP(dir.path.split("/")[1])) < 2025; + const unsortedFiles = dir.getPublicChildren(); + const sorter = dir.dirsort + ? sorterFromDirSort(dir.dirsort) + : isNumericallyOrdered(unsortedFiles) + ? sortNumerically + : isCotyledon + ? sortChronologicalStartToEnd + : sortDefault; + const sorted = unsortedFiles.sort(sorter); + const readme = sorted.find((f) => f.basename === "readme.txt"); + if (readme && isLast) activeFilename ||= "readme.txt"; + return { sorted, activeFilename, readme }; +})()> + + + + + +
    + + + +
+
+ + + + + + + + + + + + +static const priorityFiles = ["readme.txt", "index.html"]; +static function sortDefault(a: MediaFile, b: MediaFile) { + // First check if either file is in the priority list + const aIndex = priorityFiles.indexOf(a.basename.toLowerCase()); + const bIndex = priorityFiles.indexOf(b.basename.toLowerCase()); + + if (aIndex !== -1 && bIndex !== -1) { + // Both are priority files, sort by priority order + return aIndex - bIndex; + } else if (aIndex !== -1) { + // Only a is a priority file + return -1; + } else if (bIndex !== -1) { + // Only b is a priority file + return 1; + } + + // Then sort directories before files + if (a.kind !== b.kind) { + return a.kind === MediaFileKind.directory ? -1 : 1; + } + + // Finally sort by date (newest first), then by name (a-z) if dates are the same + const dateComparison = b.date.getTime() - a.date.getTime(); + if (dateComparison !== 0) { + return dateComparison; + } + // If dates are the same, sort alphabetically by name + return a.basename.localeCompare(b.basename); +} + +static function sortAlphabetically(a: MediaFile, b: MediaFile) { + // First check if either file is in the priority list + const aIndex = priorityFiles.indexOf(a.basename.toLowerCase()); + const bIndex = priorityFiles.indexOf(b.basename.toLowerCase()); + + if (aIndex !== -1 && bIndex !== -1) { + // Both are priority files, sort by priority order + return aIndex - bIndex; + } else if (aIndex !== -1) { + // Only a is a priority file + return -1; + } else if (bIndex !== -1) { + // Only b is a priority file + return 1; + } + + // If dates are the same, sort alphabetically by name + return a.basename.localeCompare(b.basename); +} + +static function sorterFromDirSort(dirsort: string) { + const array: string[] = JSON.parse(dirsort); + if (array.length === 1 && array[0] === "a-z") { + return sortAlphabetically; + } + return (a: MediaFile, b: MediaFile) => { + const aIndex = array.indexOf(a.basename); + const bIndex = array.indexOf(b.basename); + if (bIndex == -1 && aIndex >= 0) return -1; + if (aIndex == -1 && bIndex >= 0) return 1; + if (aIndex >= 0 && bIndex >= 0) { + return aIndex - bIndex; + } + return sortDefault(a, b); + }; +} + +static function isNumericallyOrdered(files: MediaFile[]) { + return !files.some((x) => Number.isNaN(parseInt(x.basenameWithoutExt))); +} + +static function sortNumerically(a: MediaFile, b: MediaFile) { + const n1 = parseInt(a.basenameWithoutExt); + const n2 = parseInt(a.basenameWithoutExt); + return n1 - n2; +} + +static function sortChronologicalStartToEnd(a: MediaFile, b: MediaFile) { + const aIndex = priorityFiles.indexOf(a.basename.toLowerCase()); + const bIndex = priorityFiles.indexOf(b.basename.toLowerCase()); + + if (aIndex !== -1 && bIndex !== -1) { + return aIndex - bIndex; + } else if (aIndex !== -1) { + return -1; + } else if (bIndex !== -1) { + return 1; + } + + const dateComparison = a.date.getTime() - b.date.getTime(); + if (dateComparison !== 0) { + return dateComparison; + } + return a.basename.localeCompare(b.basename); +} + +import { UNWRAP } from "@clo/lib/assert"; +import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts"; diff --git a/src/file-viewer/tags/media-list-item.marko b/src/file-viewer/tags/media-list-item.marko new file mode 100644 index 0000000000000000000000000000000000000000..ecb9c6782dcdcb6ae4e64602c630152f410ef2ea --- /dev/null +++ b/src/file-viewer/tags/media-list-item.marko @@ -0,0 +1,74 @@ +export interface Input { + file: MediaFile; + active?: boolean; + noDate?: boolean; +} + +static const unknownDate = new Date("1970-01-03"); +static const unknownDateWithKnownYear = new Date("1970-02-20"); + + + + + + i.toString().padStart(2, "0")) + .join(".") +)> + + { + let meta = + file.kind === MediaFileKind.directory + ? string.formatByteSize(file.size) + : (file.duration ?? 0) > 0 + ? format.formatDuration(file.duration!) + : null; + if (meta && path.dirname(file.path) !== "/") { + meta = `(${meta})`; + } + return meta; +})()> + +
  • + + + + + + + + + ${meta} + + + + + + ${basenameWithoutExt}${file.extension} + + / + + ${meta} + + +
  • + +import * as path from "node:path"; +import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts"; +import * as format from "#src/file-viewer/format.ts"; +import * as rules from "#src/file-viewer/rules.ts"; +import * as string from "@clo/lib/string"; diff --git a/src/file-viewer/tags/media-panel.marko b/src/file-viewer/tags/media-panel.marko new file mode 100644 index 0000000000000000000000000000000000000000..49d660aec255c970e12219ba7853b6878baa2b5d --- /dev/null +++ b/src/file-viewer/tags/media-panel.marko @@ -0,0 +1,165 @@ +export interface Input { + file: MediaFile; + isLast: boolean; + activeFilename: string | null; + hasCotyledonCookie: boolean; + noWrapper?: boolean; +} + +import "../views/clofi.css"; +client import "../clofi.client.ts"; +// ensure video stuff is loaded for CSRs +client import "../../tags/clover-video.client.ts"; + +static const cotyledonEndingFile = "/2024/for everone"; + + + + + + clo's files + + ${file.basename}/
    + + + ${file.basename} +
    + + + + + + +
    + <${content}/> +
    +
    + + + // special cased canvases + + + + + client import "../scripts/canvas_2017.client.ts"; + + + + client import "../scripts/canvas_2018.client.ts"; + + + + client import "../scripts/canvas_2019.client.ts"; + + + + client import "../scripts/canvas_2020.client.ts"; + + + + client import "../scripts/canvas_2021.client.ts"; + + + + client import "../scripts/canvas_2022.client.ts"; + + + + client import "../scripts/canvas_2023.client.ts"; + + + + client import "../scripts/canvas_2024.client.ts"; + + + + // header + + +
    + + +
    + + + +
    +
    + + // content + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + +import { UNWRAP } from "@clo/lib/assert"; +import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts"; +import * as format from "#src/file-viewer/format.ts"; +import * as rules from "#src/file-viewer/rules.ts"; +import * as path from "node:path"; diff --git a/src/file-viewer/tags/media-root-dir.marko b/src/file-viewer/tags/media-root-dir.marko new file mode 100644 index 0000000000000000000000000000000000000000..840ff8349093776bf392f9f063fcfbd5956303a1 --- /dev/null +++ b/src/file-viewer/tags/media-root-dir.marko @@ -0,0 +1,76 @@ +export interface Input { + dir: MediaFile; + activeFilename: string | null; + isLast: boolean; + hasCotyledonCookie: boolean; +} + + + + + + + + +
    + +
      + +
    + + + +

    ${key}

    + +
    + +
    + + + + + + + + + cotyledon + + +
    + + + + + +import { MediaFile } from "#src/file-viewer/models/MediaFile.ts"; +import { UNWRAP } from "@clo/lib/assert"; +import * as sort from "../sort.ts"; diff --git a/src/file-viewer/tags/view-audio.marko b/src/file-viewer/tags/view-audio.marko new file mode 100644 index 0000000000000000000000000000000000000000..80ead08e5aeedecfaf5b66b8e15febfa943e9646 --- /dev/null +++ b/src/file-viewer/tags/view-audio.marko @@ -0,0 +1,44 @@ +export interface Input { + file: MediaFile; + onlyAudio?: boolean; +} + + + { + const parent = file.getParent(); + if (parent && !onlyAudio) { + const siblings = parent.getPublicChildren(); + + // show lyrics only if + // - this media file is the only audio file + // - there is lyrics.txt in the same directory + const audioFiles = siblings.filter( + (f) => + f.kind === MediaFileKind.file && + extensionToViewer[path.extname(f.basename)] === AudioView, + ); + if ( + audioFiles.length === 1 && + siblings.find((f) => f.basename === "lyrics.txt") + ) { + return siblings.find((f) => f.basename === "lyrics.txt")!; + } + } + + return null; +}> + +
    +

    + lyrics +

    + +
    + +import * as path from "node:path"; +import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts"; diff --git a/src/file-viewer/tags/view-chat.marko b/src/file-viewer/tags/view-chat.marko new file mode 100644 index 0000000000000000000000000000000000000000..194e39dfafae2e90f4d8e5405a3490f12e0322ed --- /dev/null +++ b/src/file-viewer/tags/view-chat.marko @@ -0,0 +1,87 @@ +export interface Input { + file: MediaFile; +} + +static function highlightConvo(text: string) { + text = text.replace(/^#mode=convo\n/, ""); + + const lines = text.split("\n"); + const paras: { speaker: string | null; lines: string[] }[] = []; + let currentPara: string[] = []; + let currentSpeaker: string | null = null; + let firstSpeaker = null; + + const speakers: Record = {}; + const getSpeaker = (s: string) => { + if (s[1] === " " && speakers[s[0]]) { + return s[0]; + } + return null; + }; + + for (const line of lines) { + let trimmed = line.trim(); + if (line.startsWith("#")) { + // parse #X=Y + const [_, speaker, color] = trimmed.match(/^#(.)=(.*)$/)!; + speakers[speaker] = color; + continue; + } + if (trimmed === "") { + continue; + } + let speaker = getSpeaker(trimmed); + if (speaker) { + trimmed = trimmed.substring(speaker.length).trimStart(); + speaker = speakers[speaker]; + } else { + speaker = "me"; + } + + trimmed = trimmed.replace( + /\[IMG:(\/file\/[^\]]+)\]/g, + 'attachment', + ); + + if (trimmed === "---" && speaker === "me") { + trimmed = "
    "; + } + + if (speaker === currentSpeaker) { + currentPara.push(trimmed); + } else { + if (currentPara.length > 0) { + paras.push({ + speaker: currentSpeaker, + lines: currentPara, + }); + currentPara = []; + } + currentPara = [trimmed]; + currentSpeaker = speaker; + firstSpeaker ??= speaker; + } + } + + if (currentPara.length > 0) { + paras.push({ + speaker: currentSpeaker, + lines: currentPara, + }); + } + + return paras + .map(({ speaker, lines }) => { + return `
    ${lines + .map((line) => `
    ${line}
    `) + .join("\n")}
    `; + }) + .join("\n"); +} + + +$!{highlightConvo(contents)}
    + +import type { MediaFile } from "#src/file-viewer/models/MediaFile.ts"; diff --git a/src/file-viewer/tags/view-code.marko b/src/file-viewer/tags/view-code.marko new file mode 100644 index 0000000000000000000000000000000000000000..ee9258ad751a6a6286a103bb19286003f93dd939 --- /dev/null +++ b/src/file-viewer/tags/view-code.marko @@ -0,0 +1,18 @@ +export interface Input { + file: MediaFile; +} + + + + +
    $!{contents}
    + + + ${" "}1_000_000> + + + + + diff --git a/src/file-viewer/tags/view-download-not-computed.marko b/src/file-viewer/tags/view-download-not-computed.marko new file mode 100644 index 0000000000000000000000000000000000000000..2c963ab8bddb90cada0dc947add6a53b7c0ac67d --- /dev/null +++ b/src/file-viewer/tags/view-download-not-computed.marko @@ -0,0 +1,18 @@ +export interface Input { + file: MediaFile; +} + + +
    +

    + This file has not been pre-processed for code highlighting. This either + means it is not a valid UTF-8 text file (.ts can be TypeScript or + an MPEG "Transport Stream"), or there is a bug in the file indexing logic. +

    +

    + Instead, you may download it: + + ${file.basename} + . +

    +
    diff --git a/src/file-viewer/tags/view-download-too-big.marko b/src/file-viewer/tags/view-download-too-big.marko new file mode 100644 index 0000000000000000000000000000000000000000..70daceae48b8e66548fb8b71e8a04f6dcc500b76 --- /dev/null +++ b/src/file-viewer/tags/view-download-too-big.marko @@ -0,0 +1,19 @@ +export interface Input { + file: MediaFile; +} + + +
    +

    + This file is too large to embed in the browser ( + {string.formatByteSize(file.size)}. +

    +

    + Instead, you may download it: + + ${file.basename} + . +

    +
    + +import * as string from "@clo/lib/string"; diff --git a/src/file-viewer/tags/view-download.marko b/src/file-viewer/tags/view-download.marko new file mode 100644 index 0000000000000000000000000000000000000000..de2faf896b4336611584588864e4fd1c86b977b2 --- /dev/null +++ b/src/file-viewer/tags/view-download.marko @@ -0,0 +1,61 @@ +export interface Input { + file: MediaFile; +} + + + + +

    + .${ext} files are + + Fusion + ${name}. +

    +

    + Blackmagic Fusion is paid-only since mid-2019 (the "Studio" version). The + last free version is Fusion 9.0.2, which exists for MacOS, Linux, and + Microsoft Windows on + + Blackmagic Design's support page + . +

    +

    + Alternatively, you can use DaVinci Resolve, however I do not recommend it as + it is very unstable, at least as of when I last tried version 18. +

    +
    + + ${file.basename} + + + + +

    + .blend files can be open in + Blender. +

    +