initial commit

main
Mari 10 months ago
commit bad4277a5c
  1. 3
      .gitignore
  2. 8
      .idea/.gitignore
  3. 12
      .idea/chattakeoutreader.iml
  4. 6
      .idea/jsLibraryMappings.xml
  5. 8
      .idea/modules.xml
  6. 6
      .idea/vcs.xml
  7. 902
      index.ts
  8. 61
      package-lock.json
  9. 20
      package.json
  10. 16
      tsconfig.json
  11. 93
      typeassert.ts

3
.gitignore vendored

@ -0,0 +1,3 @@
node_modules
*.js
*.js.map

8
.idea/.gitignore vendored

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/chattakeoutreader.iml" filepath="$PROJECT_DIR$/.idea/chattakeoutreader.iml" />
</modules>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

@ -0,0 +1,902 @@
import {join} from "path"
import {readFile, writeFile} from "fs/promises"
import {
asDate,
asNumber,
assertField,
assertNoExtraFields,
asString,
isArray,
isObject, isString,
} from "./typeassert";
import {DateTime, Duration, IANAZone, Zone} from "luxon";
import {default as escapeHTML} from "escape-html";
interface UserMessages {
readonly group: readonly User[]
readonly messages: readonly Message[]
}
function groupToCompanionNames(group: readonly User[], localEmail: string): readonly string[] {
return group.filter((user) => user.email !== localEmail).map((user) => user.name)
}
async function handleUserDir(path: string): Promise<UserMessages> {
const messagesPath = join(path, "messages.json")
const messages = readFile(messagesPath, {encoding: "utf-8"})
.then((messagesText) => JSON.parse(messagesText))
.then((messagesJson) => handleMessageFile(messagesJson, messagesPath))
const groupPath = join(path, "group_info.json")
const group = readFile(groupPath, {encoding: "utf-8"})
.then((groupText) => JSON.parse(groupText))
.then((groupJson) => handleGroupFile(groupJson, groupPath))
return {
group: await group,
messages: await messages,
}
}
const groupFileExpectedFields = new Set(["members"])
async function handleGroupFile(groupJson: unknown, groupFilePath: string): Promise<readonly User[]> {
if (!(isObject(groupJson, groupFilePath) && assertField(groupJson, "members", groupFilePath))) {
return []
}
assertNoExtraFields(groupJson, groupFileExpectedFields)
const context = `${groupFilePath} .members`
if (!isArray(groupJson.members, context)) {
return []
}
const result: User[] = []
let index = -1;
for (const memberJson of groupJson.members) {
index += 1
const member = await handleUser(memberJson, `${context}[${index}]`)
if (member) {
result.push(member)
}
}
return result
}
interface Message {
readonly topic: string
readonly text?: string
readonly createdDate: DateTime
readonly creator: User
readonly annotations: readonly Annotation[]
readonly attachedFiles: readonly AttachedFile[]
}
const messageFileExpectedFields = new Set(["messages"])
async function handleMessageFile(messagesJson: unknown, messageFilePath: string): Promise<readonly Message[]> {
if (!(isObject(messagesJson, messageFilePath) && assertField(messagesJson, "messages", messageFilePath))) {
return []
}
assertNoExtraFields(messagesJson, messageFileExpectedFields)
return handleMessageList(messagesJson.messages, messageFilePath)
}
async function handleMessageList(messagesJson: unknown, messageFile: string): Promise<readonly Message[]> {
const context = `${messageFile} .messages`
if (!isArray(messagesJson, context)) {
return []
}
const result: Message[] = []
let index = -1;
for (const messageJson of messagesJson) {
index += 1
const message = await handleMessage(messageJson, `${context}[${index}]`)
if (message) {
result.push(message)
}
}
return result
}
const messageExpectedFields = new Set(["text", "created_date", "topic_id", "creator", "annotations", "attached_files"])
async function handleMessage(messageJson: unknown, context: string): Promise<Message|null> {
if (!isObject(messageJson, context)) {
return null
}
assertNoExtraFields(messageJson, messageExpectedFields, context)
const text = "text" in messageJson
? asString(messageJson.text, context + ".text") : undefined
const createdDate = assertField(messageJson, "created_date", context)
? asDate(messageJson.created_date, context + ".created_date") : null
const topic = assertField(messageJson, "topic_id", context)
? asString(messageJson.topic_id, context + ".topic_id") : null
const creator = assertField(messageJson, "creator", context)
? await handleUser(messageJson.creator, context + ".creator") : null
const annotations = "annotations" in messageJson
? await handleAnnotationList(messageJson.annotations, context + ".annotations") : []
const attachedFiles = "attached_files" in messageJson
? await handleAttachedFilesList(messageJson.attached_files, context + ".attachedFiles") : []
if (text === null || createdDate === null || topic === null || creator === null) {
return null
}
return {
text,
createdDate,
topic,
creator,
annotations,
attachedFiles
}
}
interface User {
readonly name: string
readonly email: string
readonly userType: string
}
const creatorExpectedFields = new Set(["name", "email", "user_type"])
async function handleUser(creatorJson: unknown, context: string): Promise<User|null> {
if (!isObject(creatorJson, context)) {
return null
}
assertNoExtraFields(creatorJson, creatorExpectedFields, context)
const name = assertField(creatorJson, "name", context)
? asString(creatorJson.name, context + ".name") : null
const email = assertField(creatorJson, "email", context)
? asString(creatorJson.email, context + ".email") : null
const userType = assertField(creatorJson, "user_type", context)
? asString(creatorJson.user_type, context + ".user_type") : null
if (name === null || email === null || userType === null) {
return null
}
return {
name,
email,
userType
}
}
interface BaseAnnotation {
readonly type: string
readonly start: number
readonly length: number
}
interface FormatAnnotation extends BaseAnnotation {
readonly type: "format"
readonly format: FormatType
}
interface URLAnnotation extends BaseAnnotation {
readonly type: "url"
readonly url: string
}
type Annotation = FormatAnnotation | URLAnnotation
async function handleAnnotationList(annotationsJson: unknown, context: string): Promise<readonly Annotation[]> {
if (!isArray(annotationsJson, context)) {
return []
}
const result: Annotation[] = []
let index = -1;
for (const annotationJson of annotationsJson) {
index += 1
const annotation = await handleAnnotation(annotationJson, `${context}[${index}]`)
if (annotation) {
result.push(annotation)
}
}
return result
}
const annotationExpectedFields = new Set(["start_index", "length", "format_metadata", "youtube_metadata", "url_metadata", "drive_metadata"])
async function handleAnnotation(annotationJson: unknown, context: string): Promise<Annotation|null> {
if (!isObject(annotationJson, context)) {
return null
}
assertNoExtraFields(annotationJson, annotationExpectedFields, context)
const start = assertField(annotationJson, "start_index", context)
? asNumber(annotationJson.start_index, context + ".start_index") : null
const length = assertField(annotationJson, "length", context)
? asNumber(annotationJson.length, context + ".length") : null
if (start === null || length === null) {
return null
}
const types = new Set<"format"|"youtube"|"url"|"drive">()
let format: FormatType|null = null
if ("format_metadata" in annotationJson) {
types.add("format")
format = await handleFormatMetadata(annotationJson.format_metadata, context + ".format_metadata")
}
let url: string|null = null
if ("youtube_metadata" in annotationJson) {
types.add("youtube")
url = await handleYoutubeMetadata(annotationJson.youtube_metadata, context + ".youtube_metadata")
}
if ("url_metadata" in annotationJson) {
types.add("url")
url = await handleURLMetadata(annotationJson.url_metadata, context + ".url_metadata")
}
if ("drive_metadata" in annotationJson) {
types.add("drive")
url = await handleDriveMetadata(annotationJson.drive_metadata, context + ".drive_metadata")
}
if (types.size > 1) {
console.log(`Too many metadata - ${Array.from(types).join(", ")} (in ${context})`)
} else if (types.size === 0) {
console.log(`No metadata (in ${context})`)
}
if (format !== null) {
return {
type: "format",
start,
length,
format,
}
} else if (url !== null) {
return {
type: "url",
start,
length,
url,
}
} else {
console.log(`Failed to parse metadata for ${Array.from(types).join(", ")} (in ${context})`)
return null
}
}
enum FormatType {
BOLD = "BOLD",
ITALIC = "ITALIC",
STRIKE = "STRIKE",
}
const formatMetadataExpectedFields = new Set(["format_type"])
async function handleFormatMetadata(formatMetadataJson: unknown, context: string): Promise<FormatType|null> {
if (!isObject(formatMetadataJson, context)) {
return null
}
assertNoExtraFields(formatMetadataJson, formatMetadataExpectedFields, context)
if (!assertField(formatMetadataJson, "format_type", context) || !isString(formatMetadataJson.format_type, context + ".format_type")) {
return null
}
switch (formatMetadataJson.format_type) {
case FormatType.BOLD:
return FormatType.BOLD
case FormatType.ITALIC:
return FormatType.ITALIC
case FormatType.STRIKE:
return FormatType.STRIKE
default:
console.log(`unknown format type ${formatMetadataJson.format_type}`)
return null
}
}
const youtubeMetadataExpectedFields = new Set(["id", "start_time"])
async function handleYoutubeMetadata(youtubeMetadataJson: unknown, context: string): Promise<string|null> {
if (!isObject(youtubeMetadataJson, context)) {
return null
}
assertNoExtraFields(youtubeMetadataJson, youtubeMetadataExpectedFields, context)
const id = assertField(youtubeMetadataJson, "id", context)
? asString(youtubeMetadataJson.id, context + ".id") : null
const startTime = assertField(youtubeMetadataJson, "start_time", context)
? asNumber(youtubeMetadataJson.start_time, context + ".start_time") : null
if (id === null || startTime === null) {
return null
}
return `https://youtube.com/watch?v=${id}&t=${startTime}s`
}
const urlMetadataExpectedFields = new Set(["title", "snippet", "image_url", "url"])
async function handleURLMetadata(urlMetadataJson: unknown, context: string): Promise<string|null> {
if (!isObject(urlMetadataJson, context)) {
return null
}
assertNoExtraFields(urlMetadataJson, urlMetadataExpectedFields, context)
const url = assertField(urlMetadataJson, "url", context) && isObject(urlMetadataJson.url) && assertField(urlMetadataJson.url, "private_do_not_access_or_else_safe_url_wrapped_value", context + ".url")
? asString(urlMetadataJson.url.private_do_not_access_or_else_safe_url_wrapped_value, context + ".url") : null
if (url === null) {
return null
}
return url
}
const driveMetadataExpectedFields = new Set(["id", "title", "thumbnail_url"])
async function handleDriveMetadata(driveMetadataJson: unknown, context: string): Promise<string|null> {
if (!isObject(driveMetadataJson, context)) {
return null
}
const id = assertField(driveMetadataJson, "id", context)
? asString(driveMetadataJson.id, context + ".id") : null
assertNoExtraFields(driveMetadataJson, driveMetadataExpectedFields, context)
if (id === null) {
return null
}
return `https://drive.google.com/file/d/${id}/view`
}
interface AttachedFile {
originalName: string
exportName: string
}
async function handleAttachedFilesList(attachedFilesJson: unknown, context: string): Promise<readonly AttachedFile[]> {
if (!isArray(attachedFilesJson, context)) {
return []
}
const result: AttachedFile[] = []
let index = -1;
for (const attachedFileJson of attachedFilesJson) {
index += 1
const attachedFile = await handleAttachedFile(attachedFileJson, `${context}[${index}]`)
if (attachedFile) {
result.push(attachedFile)
}
}
return result
}
const attachedFileExpectedFields = new Set(["original_name", "export_name"])
async function handleAttachedFile(attachedFileJson: unknown, context: string): Promise<AttachedFile|null> {
if (!isObject(attachedFileJson, context)) {
return null
}
assertNoExtraFields(attachedFileJson, attachedFileExpectedFields, context)
const originalName = assertField(attachedFileJson, "original_name", context)
? asString(attachedFileJson.original_name, context + ".original_name") : null
const exportName = assertField(attachedFileJson, "export_name", context)
? asString(attachedFileJson.export_name, context + ".export_name") : null
if (originalName === null || exportName === null) {
return null
}
return {
originalName,
exportName,
}
}
const DefaultMessageZone = IANAZone.create("UTC")
const DefaultMessageThreshold = Duration.fromObject({hours: 4})
async function divideMessages(messages: readonly Message[], threshold?: Duration, targetTimezone?: Zone): Promise<readonly (readonly Message[])[]> {
const results: (readonly Message[])[] = []
if (messages.length === 0) {
return results
}
let startPoint = 0
let currentPoint = 1
let startDate = messages[0].createdDate.setZone(targetTimezone ?? DefaultMessageZone)
let previousDateAdjusted: DateTime = startDate.plus(threshold ?? DefaultMessageThreshold)
for (; currentPoint < messages.length; currentPoint += 1) {
const currentMessage = messages[currentPoint]
const currentDate = currentMessage.createdDate.setZone(targetTimezone ?? DefaultMessageZone)
if (startPoint < currentPoint && !startDate.hasSame(currentDate, "day") && (previousDateAdjusted <= currentDate)) {
results.push(messages.slice(startPoint, currentPoint))
startPoint = currentPoint
startDate = currentDate
}
previousDateAdjusted = currentDate.plus(threshold ?? DefaultMessageThreshold)
}
results.push(messages.slice(startPoint))
return results
}
interface Interruption {
offset: number
interruption: string|{annotation: Annotation, index: number, start: boolean, end: boolean}
}
const MessageContentInterruptions = /\n\n|\n/
async function renderMessageText(text: string, annotations: readonly Annotation[]): Promise<string> {
let remainingText = text
let remainingAnnotations: (Annotation & {index: number, started: boolean, ended: boolean})[] = annotations.slice()
.map((annotation, index) => ({...annotation, index, started: false, ended: false}))
let position = 0;
const result = ['<p>']
const stack: string[] = []
let nextInterruption: Interruption|null = null
function findNextInterruption(): Interruption|null {
const nextEscape = MessageContentInterruptions.exec(remainingText)
let upcomingInterruption: Interruption|null = nextEscape ? {offset: nextEscape.index, interruption: nextEscape[0]} : null
for (const annotation of remainingAnnotations) {
const nextOffset = upcomingInterruption?.offset ?? Number.POSITIVE_INFINITY
const nextInterruption = upcomingInterruption?.interruption
const nextAnno = nextInterruption && typeof nextInterruption !== "string" && "annotation" in nextInterruption ? nextInterruption : null
const effectiveStart = annotation.start - position
const effectiveEnd = effectiveStart + annotation.length
if (!annotation.started) {
if (effectiveStart < nextOffset || (effectiveStart === nextOffset && annotation.index < (nextAnno?.index ?? Number.POSITIVE_INFINITY))) {
upcomingInterruption = {
offset: effectiveStart,
interruption: {
annotation,
index: annotation.index,
start: true,
end: (effectiveStart === effectiveEnd)
}
}
}
} else if (!annotation.ended) {
if (effectiveEnd < nextOffset || (effectiveEnd === nextOffset && annotation.index > (nextAnno?.index ?? Number.NEGATIVE_INFINITY))) {
upcomingInterruption = {
offset: effectiveEnd,
interruption: {
annotation,
index: annotation.index,
start: false,
end: true
}
}
}
} else {
console.log(`already exhausted annotation ${JSON.stringify(annotation)}`)
}
}
return upcomingInterruption
}
while ((nextInterruption = findNextInterruption())) {
let offset = nextInterruption.offset
result.push(escapeHTML(remainingText.slice(0, offset)))
if (typeof nextInterruption.interruption === "string") {
switch (nextInterruption.interruption) {
case "\n\n":
result.push("</p><p>")
break;
case "\n":
result.push("<br>")
break;
default:
console.log(`unknown string interruption ${JSON.stringify(nextInterruption.interruption)}`)
break;
}
offset += nextInterruption.interruption.length
} else {
switch (nextInterruption.interruption.annotation.type) {
case "url":
if (nextInterruption.interruption.start) {
result.push(`<a href="${escapeHTML(nextInterruption.interruption.annotation.url)}">`)
stack.push("<a>")
}
if (nextInterruption.interruption.end) {
if (stack.pop() !== "<a>") {
console.log("bad stacking")
}
result.push("</a>")
}
break
case "format":
switch (nextInterruption.interruption.annotation.format) {
case FormatType.BOLD:
if (nextInterruption.interruption.start) {
result.push("<b>")
stack.push("<b>")
}
if (nextInterruption.interruption.end) {
if (stack.pop() !== "<b>") {
console.log("bad stacking")
}
result.push("</b>")
}
break
case FormatType.ITALIC:
if (nextInterruption.interruption.start) {
result.push("<i>")
stack.push("<i>")
}
if (nextInterruption.interruption.end) {
if (stack.pop() !== "<i>") {
console.log("bad stacking")
}
result.push("</i>")
}
break
case FormatType.STRIKE:
if (nextInterruption.interruption.start) {
result.push("<s>")
stack.push("<s>")
}
if (nextInterruption.interruption.end) {
if (stack.pop() !== "<s>") {
console.log("bad stacking")
}
result.push("</s>")
}
break
default:
console.log(`unknown format type ${nextInterruption.interruption.annotation.format}`)
break
}
break
}
const idx = nextInterruption.interruption.index
const match = remainingAnnotations.find((anno) => anno.index === idx)
if (match && nextInterruption.interruption.start) {
match.started = true
}
if (match && nextInterruption.interruption.end) {
match.ended = true
remainingAnnotations.splice(remainingAnnotations.findIndex((anno) => anno.index === idx), 1)
}
}
remainingText = remainingText.slice(offset)
position += offset
}
result.push(escapeHTML(remainingText))
result.push("</p>")
return result.join("")
}
async function renderMessageAttachments(attachments: readonly AttachedFile[]): Promise<string> {
const result: string[] = []
for (const attachment of attachments) {
result.push(`<img src="${attachment.exportName}" alt="${attachment.originalName}">`)
}
return result.join("")
}
async function renderMessageList(messages: readonly Message[], {threshold, localEmail, displayZone}: {threshold: Duration, localEmail: string, displayZone: Zone}): Promise<readonly string[]> {
const result: string[] = []
let lastDate: DateTime|null = null
let lastAuthor: string|null = null
for (const message of messages) {
const nextDate = message.createdDate.setZone(displayZone)
const needsDatestamp = lastDate === null || !nextDate.hasSame(lastDate, "day")
const needsTimestamp = lastDate === null || nextDate >= lastDate.plus(threshold)
if (needsDatestamp || needsTimestamp) {
result.push(`<p class="datestamp">${
needsDatestamp ? escapeHTML(nextDate.toLocaleString(DateTime.DATE_FULL)) : ""}${
needsDatestamp && needsTimestamp ? "<br>" : ""}${
needsTimestamp ? escapeHTML(nextDate.toLocaleString(DateTime.TIME_SIMPLE)) : ""}</p>`)
}
const nextAuthor = message.creator.email
const isSelf = nextAuthor === localEmail
if (lastAuthor !== nextAuthor) {
result.push(`<p class="chatauthor ${isSelf ? "self" : "other"}">${escapeHTML(message.creator.name)}</p>`)
}
result.push(`<div class="chatbubble ${isSelf ? "self" : "other"}">
${message.text ? await renderMessageText(message.text, message.annotations) : ""}
${message.attachedFiles ? await renderMessageAttachments(message.attachedFiles) : ""}
</div>`)
lastDate = nextDate
lastAuthor = nextAuthor
}
return result
}
async function renderMessageLists(dividedMessages: readonly (readonly Message[])[], {group, threshold, localEmail, displayZone, destination}: {group: readonly User[], threshold: Duration, localEmail: string, displayZone: Zone, destination: string}): Promise<void> {
for (const messages of dividedMessages) {
const last = messages[messages.length - 1]
const first = messages[0]
const lastDate = last.createdDate.setZone(displayZone)
const firstDate = first.createdDate.setZone(displayZone)
const basename = firstDate.toISO({includeOffset: false, format: "basic", suppressMilliseconds: true, suppressSeconds: false, includePrefix: true})
const title = `Chat with ${groupToCompanionNames(group, localEmail).join(", ")} from ${firstDate.toLocaleString(DateTime.DATETIME_FULL)} (${localEmail})`
const head = `Chat with ${groupToCompanionNames(group, localEmail).join(", ")}`
const subhead = `From ${firstDate.toLocaleString(DateTime.DATETIME_FULL)} to ${lastDate.toLocaleString(DateTime.DATETIME_FULL)} (${localEmail})`
const messageElements = (await renderMessageList(messages, {threshold, localEmail, displayZone})).join("\n")
const html = `<!DOCTYPE html><html lang="en"><head>
<title>${escapeHTML(title)}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?&family=Braah+One&family=Karla&display=swap"><style>
.hiddenPreference {
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 1px;
border: 0;
padding: 0;
margin: 0;
clip-path: inset(0.5px);
overflow: hidden;
appearance: none;
}
body {
margin: 0;
padding: 0;
}
#bodyContainer {
min-width: 100%;
min-height: 100dvh;
height: auto;
margin: 0;
overflow: visible;
}
div.margins {
margin-left: auto;
margin-right: auto;
max-width: 700px;
}
/* Required to make the background color fill the page rather than #bodyContainer moving down with its children's margins*/
#bodyContainer::before {
content: "";
display: block;
width: 100%;
height: 1px;
background: transparent;
margin: 0;
margin-bottom: -1px;
}
.fakeTextbox {
border: 1px solid currentColor;
border-radius: 3px;
color: #222222;
padding: 4px;
margin: 0.3em;
background-color: #DDDDDDCC;
font-family: 'Karla', sans-serif;
}
#forceDark:checked ~ #bodyContainer .fakeTextbox {
color: #DDDDDD;
background-color: #222222;
}
@keyframes blinkcursor {
50% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.fakeTextbox .insertionPoint {
content: "\\200C";
border-left: 1px solid currentColor;
font-weight: 100;
animation: 700ms step-end 0s infinite alternate both running blinkcursor;
letter-spacing: 0;
}
@media screen {
#bodyContainer, #forceLight:checked ~ #bodyContainer {
border-color: #222222;
background-color: #dfbbf7;
}
#forceDark:checked ~ #bodyContainer {
background-color: #1c061d;
border-color: #2c2c2c;
}
div.header, #forceLight:checked ~ #bodyContainer div.header {
background-color: #ad75db;
border-bottom-color: #532975;
}
#forceDark:checked ~ #bodyContainer div.header {
background-color: hsl(323, 100%, 15%);
border-bottom-color: hsl(323, 65%, 28%);
}
p, #forceLight:checked ~ #bodyContainer p {
color: #222222;
}
#forceDark:checked ~ #bodyContainer p {
color: #DDDDDD;
}
div.chatbubble, #forceLight:checked ~ #bodyContainer div.chatbubble {
color: #b483e2;
}
#forceDark:checked ~ #bodyContainer div.chatbubble {
color: #361653;
}
div.chatbubble.self, #forceLight:checked ~ #bodyContainer div.chatbubble.self {
color: #53daaf;
}
#forceDark:checked ~ #bodyContainer div.chatbubble.self {
color: #094431;
}
}
@media only screen and (prefers-color-scheme: dark) {
#bodyContainer {
background-color: #1c061d;
border-color: #2c2c2c;
}
div.header {
background-color: hsl(323, 100%, 15%);
border-bottom-color: hsl(323, 65%, 28%);
}
p {
color: #DDDDDD;
}
div.chatbubble {
color: #361653;
}
div.chatbubble.self {
color: #094431;
}
}
div.header {
border-bottom-width: 1px;
border-bottom-style: solid;
padding: 0.2em 0.5em;
}
div.header p.chatname {
font-size: 1.5em;
font-family: 'Braah One', sans-serif;
font-weight: bold;
margin: 0.2em 0 -0.2em;
}
p.chatauthor {
font-family: 'Braah One', sans-serif;
font-weight: bold;
margin: 0.5em 0 -0.2em;
}
p.datestamp {
font-family: 'Karla', sans-serif;
font-weight: bold;
text-align: center;
opacity: 70%;
margin: 0.5em 0 0.2em;
}
div.chatbubble p {
font-family: 'Karla', sans-serif;
white-space: pre-wrap;
}
div.chatbubble img {
width: 100%;
height: auto;
object-fit: contain;
display: block;
}
div.header p.subtitle, div.header p.config {
font-family: 'Karla', sans-serif;
margin: 0.2em 0;
}
div.header p.config {
font-size: 0.75em
}
div.chatbubble {
position: relative;
background-color: currentColor;
border-radius: 5px;
width: fit-content;
min-height: 20px;
padding: 0.5em 0.5em;
margin: 0.2em 0;
}
div.chatbubble.loading {
width: 120px;
height: 120px;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
/* background-image: url("/static/img/fake-loading.svg"); */
}
@keyframes pulseDots {
from {
opacity: 0.2;
}
10% {
opacity: 0.2;
}
90% {
opacity: 1;
}
to {
opacity: 1;
}
}
div.fakeTextbox {
margin: 0.2em 5%;
}
div.chatbubble.typing p {
font-size: 0.8em;
animation: pulseDots 1000ms linear 0s infinite alternate-reverse both;
}
div.chatbubble.emojionly p {
font-size: 2em;
}
div.chatbubble p {
z-index: 1;
}
div.chatbubble p:first-child, div.fakeTextbox p:first-child {
margin-top: 0;
}
div.chatbubble p:last-child, div.fakeTextbox p:last-child {
margin-bottom: 0;
}
div.system {
text-align: center;
font-family: 'Karla', sans-serif;
margin: 0.5em 0.2em;
}
p.chatauthor {
margin-left: 5%;
margin-right: 5%;
text-align: left;
font-size: smaller;
font-weight: bold;
font-family: 'Braah One', sans-serif;
}
p.chatauthor.self {
text-align: right;
}
p.chatauthor.other {
text-align: left;
}
div.chatbubble.self {
margin-right: 5%;
margin-left: auto;
max-width: 80%;
}
div.chatbubble.other {
margin-left: 5%;
margin-right: auto;
max-width: 80%;
}
div.chatbubble.self::after, div.chatbubble.other::after {
content: '';
position: absolute;
bottom: 5px;
width: 0;
height: 0;
border: 5px solid transparent;
z-index: 0;
}
div.chatbubble.other::after {
left: 0;
border-right-color: currentColor;
border-left: 0;
margin-left: -4px;
}
div.chatbubble.self::after {
right: 0;
border-left-color: currentColor;
border-right: 0;
margin-right: -4px;
}
#useOSTheme:checked ~ #bodyContainer label#OSTheme,
#forceLight:checked ~ #bodyContainer label#light,
#forceDark:checked ~ #bodyContainer label#dark {
font-weight: bold;
}
#useOSTheme:checked ~ #bodyContainer label#OSTheme::before,
#forceLight:checked ~ #bodyContainer label#light::before,
#forceDark:checked ~ #bodyContainer label#dark::before {
content: '[';
}
#useOSTheme:checked ~ #bodyContainer label#OSTheme::after,
#forceLight:checked ~ #bodyContainer label#light::after,
#forceDark:checked ~ #bodyContainer label#dark::after
{
content: ']';
}
</style>
</head><body><input type="radio" id="useOSTheme" name="theme" value="auto" checked class="hiddenPreference"><input type="radio" id="forceLight" name="theme" value="light" class="hiddenPreference"><input type="radio" id="forceDark" name="theme" value="dark" class="hiddenPreference"><div id="bodyContainer">
<div class="header">
<div class="margins">
<p class="chatname">${escapeHTML(head)}</p>
<p class="subtitle">${escapeHTML(subhead)}</p>
<p class="config">Theme&colon; <label for="useOSTheme" id="OSTheme">Auto</label> / <label for="forceLight" id="light">Light</label> / <label for="forceDark" id="dark">Dark</label></p>
</div>
</div>
<div class="margins">${messageElements}</div>
</div></body></html>`
await writeFile(join(destination, basename + ".html"), html, {encoding: 'utf-8'})
}
}
async function main(directory: string, localEmail?: string, zoneName?: string, destination?: string) {
const zone = zoneName ? IANAZone.create(zoneName) : DateTime.local().zone
const contents = await handleUserDir(directory ?? ".")
const dividedMessagesReya = await divideMessages(contents.messages, Duration.fromObject({hours: 4}), zone)
await renderMessageLists(dividedMessagesReya, {
group: contents.group,
threshold: Duration.fromObject({hours: 1}),
localEmail: localEmail ?? "",
displayZone: zone,
destination: destination ?? directory ?? "."})
}
const [directory, localEmail, zoneName, destination] = process.argv.slice(2)
main(directory, localEmail, zoneName, destination).then(() => console.log("done")).catch((ex) => console.log(ex))

61
package-lock.json generated

@ -0,0 +1,61 @@
{
"name": "chattakeoutreader",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chattakeoutreader",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@types/escape-html": "^1.0.2",
"@types/luxon": "^3.3.0",
"@types/node": "^20.4.2",
"escape-html": "^1.0.3",
"luxon": "^3.3.0",
"typescript": "^5.1.6"
}
},
"node_modules/@types/escape-html": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.2.tgz",
"integrity": "sha512-gaBLT8pdcexFztLSPRtriHeXY/Kn4907uOCZ4Q3lncFBkheAWOuNt53ypsF8szgxbEJ513UeBzcf4utN0EzEwA=="
},
"node_modules/@types/luxon": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.0.tgz",
"integrity": "sha512-uKRI5QORDnrGFYgcdAVnHvEIvEZ8noTpP/Bg+HeUzZghwinDlIS87DEenV5r1YoOF9G4x600YsUXLWZ19rmTmg=="
},
"node_modules/@types/node": {
"version": "20.4.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz",
"integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw=="
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/luxon": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.3.0.tgz",
"integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==",
"engines": {
"node": ">=12"
}
},
"node_modules/typescript": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
"integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

@ -0,0 +1,20 @@
{
"name": "chattakeoutreader",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"scripts": {
"start": "tsc && node index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/escape-html": "^1.0.2",
"@types/luxon": "^3.3.0",
"@types/node": "^20.4.2",
"escape-html": "^1.0.3",
"luxon": "^3.3.0",
"typescript": "^5.1.6"
}
}

@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"sourceMap": true,
"noImplicitAny": true,
"strictNullChecks": true,
"esModuleInterop": true,
"alwaysStrict": true,
"strict": true,
"noFallthroughCasesInSwitch": true
},
"exclude": [
"node_modules"
]
}

@ -0,0 +1,93 @@
import {DateTime, IANAZone} from "luxon";
export type NullableType = "function"|"string"|"object"|"number"|"bigint"|"boolean"|"undefined"|"symbol"|"null"|"array"
export function nullableType(x: unknown): NullableType {
return x === null ? "null" : Array.isArray(x) ? "array" : typeof x
}
export function assertType(x:unknown, expected: "array", context?: string): x is readonly unknown[]
export function assertType(x:unknown, expected: "null", context?: string): x is null
export function assertType(x:unknown, expected: "symbol", context?: string): x is symbol
export function assertType(x:unknown, expected: "undefined", context?: string): x is undefined
export function assertType(x:unknown, expected: "boolean", context?: string): x is boolean
export function assertType(x:unknown, expected: "bigint", context?: string): x is bigint
export function assertType(x:unknown, expected: "number", context?: string): x is number
export function assertType(x:unknown, expected: "object", context?: string): x is object
export function assertType(x:unknown, expected: "string", context?: string): x is string
export function assertType(x:unknown, expected: "function", context?: string): x is Function
export function assertType(x: unknown, expected: NullableType, context?: string):
x is Function|string|object|number|bigint|boolean|undefined|symbol|null|readonly unknown[] {
const actual = nullableType(x)
if (expected !== actual) {
console.log(`expected ${expected}, was ${actual} ${context ? `(in ${context})` : ""}`)
return false
} else {
return true
}
}
export function isObject(x: unknown, context?: string): x is object {
return assertType(x, "object", context)
}
export function isArray(x: unknown, context?: string): x is readonly unknown[] {
return assertType(x, "array", context)
}
export function isString(x: unknown, context?: string): x is string {
return assertType(x, "string", context)
}
export function asString(x: unknown, context?: string): string|null {
if (!isString(x, context)) {
return null
}
return x
}
export function isNumber(x: unknown, context?: string): x is number {
return assertType(x, "number", context)
}
export function asNumber(x: unknown, context?: string): number|null {
if (!isNumber(x, context)) {
return null
}
return x
}
export function isDate(x: unknown, context?: string): x is string {
return asDate(x, context) !== null
}
export function asDate(x: unknown, context?: string): DateTime|null {
if (!isString(x, context)) {
return null
}
const result = DateTime.fromFormat(x, "EEEE', 'MMMM' 'd', 'yyyy' at 'h:mm:ss''a' 'z", {zone: IANAZone.create("UTC"), setZone: true, locale: "en-US"})
if (!result.isValid) {
return null
}
return result
}
export function assertField<T extends string|symbol>(x: object, field: T, context?: string): x is object & {[ key in T ]: unknown} {
if (!(field in x)) {
console.log(`expected ${String(field)} not found${context ? ` (in ${context})` : ""}`)
return false
}
return true
}
export function assertNoExtraFields(x: object, known: Set<string>, context?: string): boolean {
const unknown = Object.keys(x).filter(key => !known.has(key))
if (unknown.length > 1) {
console.log(`unknown fields ${unknown.join(", ")}${context ? ` (in ${context})` : ""}`)
return false
} else if (unknown.length > 0) {
console.log(`unknown field ${unknown[0]}${context ? ` (in ${context})` : ""}`)
return false
}
return true
}
Loading…
Cancel
Save