Private Torn diary for players, wanted items, notes, reminders and events.
// ==UserScript==
// @name Torn Diary
// @namespace https://www.torn.com/
// @version 1.1.1
// @description Private Torn diary for players, wanted items, notes, reminders and events.
// @author ST4TIC
// @match https://www.torn.com/profiles.php*
// @grant none
// @run-at document-idle
// @license All Rights Reserved
// ==/UserScript==
(() => {
'use strict';
const TD = {
APP_ID: 'tdiary-app',
LAUNCHER_ID: 'tdiary-launcher',
STYLE_ID: 'tdiary-styles',
STORAGE_KEY: 'tornDiary_v1_entries',
SETTINGS_KEY: 'tornDiary_v1_settings',
LAUNCHER_POSITION_KEY: 'tornDiary_v1_launcherPosition',
LOAD_DELAY: 2000
};
const TABS = {
players: {
label: 'Players',
icon: '👤',
type: 'player'
},
items: {
label: 'Items Wanted',
icon: '📦',
type: 'item'
},
notes: {
label: 'Notes',
icon: '📝',
type: 'note'
},
reminders: {
label: 'Reminders',
icon: '🔔',
type: 'reminder'
},
faction: {
label: 'Faction Events',
icon: '🛡️',
type: 'faction'
},
torn: {
label: 'Torn Events',
icon: '📅',
type: 'torn'
},
settings: {
label: 'Settings',
icon: '⚙️',
type: null
}
};
const DEFAULT_SETTINGS = {
startTab: 'players',
dueSoonHours: 24,
apiKey: ''
};
const state = {
entries: [],
settings: { ...DEFAULT_SETTINGS },
activeTab: 'players',
selectedId: null,
search: '',
timer: null
};
function createId() {
return `${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 10)}`;
}
function nowISO() {
return new Date().toISOString();
}
function escapeHTML(value = '') {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function linkify(value = '') {
return escapeHTML(value).replace(
/(https?:\/\/[^\s<]+)/gi,
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
);
}
function parseDate(value) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime())
? null
: date;
}
function formatDate(value) {
const date = parseDate(value);
if (!date) {
return 'Not set';
}
return new Intl.DateTimeFormat('en-AU', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date);
}
function formatCountdown(value) {
const target = parseDate(value);
if (!target) {
return 'No date set';
}
const difference = target.getTime() - Date.now();
const absolute = Math.abs(difference);
const totalMinutes = Math.floor(absolute / 60000);
const days = Math.floor(totalMinutes / 1440);
const hours = Math.floor((totalMinutes % 1440) / 60);
const minutes = totalMinutes % 60;
const parts = [];
if (days > 0) {
parts.push(`${days}d`);
}
if (hours > 0 || days > 0) {
parts.push(`${hours}h`);
}
parts.push(`${minutes}m`);
return difference >= 0
? `${parts.join(' ')} remaining`
: `${parts.join(' ')} overdue`;
}
function loadJSON(key, fallback) {
try {
const stored = localStorage.getItem(key);
if (!stored) {
return fallback;
}
return JSON.parse(stored);
} catch (error) {
console.error('[Torn Diary]', error);
return fallback;
}
}
function loadData() {
const entries = loadJSON(
TD.STORAGE_KEY,
[]
);
const settings = loadJSON(
TD.SETTINGS_KEY,
DEFAULT_SETTINGS
);
state.entries = Array.isArray(entries)
? entries
: [];
state.settings = {
...DEFAULT_SETTINGS,
...(settings || {})
};
state.activeTab = TABS[state.settings.startTab]
? state.settings.startTab
: 'players';
}
function saveEntries() {
localStorage.setItem(
TD.STORAGE_KEY,
JSON.stringify(state.entries)
);
updateBadge();
}
function saveSettings() {
localStorage.setItem(
TD.SETTINGS_KEY,
JSON.stringify(state.settings)
);
}
async function tornApiRequest(path) {
const apiKey = String(
state.settings.apiKey || ''
).trim();
if (!apiKey) {
throw new Error(
'Add your Torn API key in Settings first.'
);
}
const separator =
path.includes('?') ? '&' : '?';
const response = await fetch(
`https://api.torn.com/v2${path}${separator}key=${encodeURIComponent(apiKey)}`
);
const data = await response.json();
if (!response.ok) {
throw new Error(
`Torn API request failed (${response.status}).`
);
}
if (data?.error) {
throw new Error(
data.error.error ||
'Torn API request failed.'
);
}
return data;
}
function getEntry(id) {
return state.entries.find(
entry => entry.id === id
);
}
function getReminderStatus(entry) {
if (entry.completed) {
return 'completed';
}
const due = parseDate(entry.reminderAt);
if (!due) {
return 'upcoming';
}
const hours =
(due.getTime() - Date.now()) / 3600000;
if (hours < 0) {
return 'overdue';
}
if (
hours <=
Number(state.settings.dueSoonHours || 24)
) {
return 'due-soon';
}
return 'upcoming';
}
function getEventStatus(entry) {
const start = parseDate(entry.startAt);
const end = parseDate(entry.endAt);
const now = Date.now();
if (!start) {
return 'scheduled';
}
if (end && now > end.getTime()) {
return 'ended';
}
if (
end &&
now >= start.getTime() &&
now <= end.getTime()
) {
return 'live';
}
if (!end && now >= start.getTime()) {
return 'started';
}
const hours =
(start.getTime() - now) / 3600000;
if (hours <= 24) {
return 'today';
}
if (hours <= 72) {
return 'approaching';
}
return 'scheduled';
}
function getSearchText(entry) {
return [
entry.name,
entry.itemName,
entry.title,
entry.note,
entry.body,
entry.profileUrl,
entry.itemUrl,
entry.link,
...(entry.tags || []),
...(entry.updates || []).map(
update => update.text
)
]
.filter(Boolean)
.join(' ')
.toLowerCase();
}
function getVisibleEntries() {
const type = TABS[state.activeTab]?.type;
if (!type) {
return [];
}
const search =
state.search.trim().toLowerCase();
return state.entries
.filter(entry => entry.type === type)
.filter(entry => {
return (
!search ||
getSearchText(entry).includes(search)
);
})
.sort((a, b) => {
if (
Boolean(a.pinned) !==
Boolean(b.pinned)
) {
return a.pinned ? -1 : 1;
}
if (type === 'reminder') {
return (
(parseDate(a.reminderAt)?.getTime() ||
Infinity) -
(parseDate(b.reminderAt)?.getTime() ||
Infinity)
);
}
if (
type === 'faction' ||
type === 'torn'
) {
return (
(parseDate(a.startAt)?.getTime() ||
Infinity) -
(parseDate(b.startAt)?.getTime() ||
Infinity)
);
}
return (
(parseDate(b.updatedAt)?.getTime() || 0) -
(parseDate(a.updatedAt)?.getTime() || 0)
);
});
}
function injectStyles() {
if (document.getElementById(TD.STYLE_ID)) {
return;
}
const style = document.createElement('style');
style.id = TD.STYLE_ID;
style.textContent = `
#${TD.LAUNCHER_ID},
#${TD.APP_ID},
#${TD.APP_ID} * {
box-sizing: border-box;
}
#${TD.LAUNCHER_ID} {
position: fixed;
top: 194px;
right: 121px;
width: 40px;
height: 40px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #587d91;
border-radius: 6px;
background:
linear-gradient(
180deg,
#354e5b,
#1c2c34
);
color: #eef7fa;
cursor: grab;
touch-action: none;
user-select: none;
z-index: 2147482000;
box-shadow:
inset 0 1px 0 rgba(255,255,255,.12),
0 0 8px rgba(91,166,207,.25);
font-size: 18px;
}
#${TD.LAUNCHER_ID}:hover {
filter: brightness(1.15);
}
#${TD.LAUNCHER_ID}.tdiary-dragging {
cursor: grabbing;
}
#${TD.LAUNCHER_ID} .tdiary-badge {
position: absolute;
top: -7px;
right: -7px;
min-width: 17px;
height: 17px;
padding: 0 4px;
border: 1px solid rgba(255,255,255,.55);
border-radius: 999px;
background: #bd4848;
color: #fff;
font: 700 10px/15px Arial, sans-serif;
}
#${TD.APP_ID} {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(5,9,12,.76);
backdrop-filter: blur(4px);
z-index: 2147483000;
font-family: Arial, Helvetica, sans-serif;
}
#${TD.APP_ID}.tdiary-open {
display: flex;
}
#${TD.APP_ID} .tdiary-shell {
width: min(1180px, 96vw);
height: min(760px, 92vh);
display: grid;
grid-template-columns:
210px
minmax(290px, .9fr)
minmax(390px, 1.35fr);
grid-template-rows: 60px 1fr 32px;
overflow: hidden;
border: 1px solid #506e7e;
border-radius: 9px;
background: #19252b;
color: #dbe5ea;
box-shadow:
0 25px 70px rgba(0,0,0,.72);
}
#${TD.APP_ID} .tdiary-header {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 12px;
padding: 0 17px;
border-bottom: 1px solid #46616e;
background:
linear-gradient(
180deg,
#263a43,
#1b2a31
);
}
#${TD.APP_ID} .tdiary-logo {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #55788c;
border-radius: 6px;
background: #152128;
font-size: 18px;
}
#${TD.APP_ID} .tdiary-brand {
min-width: 175px;
}
#${TD.APP_ID} .tdiary-brand strong {
display: block;
color: #eef6f9;
font-size: 20px;
}
#${TD.APP_ID} .tdiary-brand span {
display: block;
margin-top: 2px;
color: #8ca1ab;
font-size: 10px;
letter-spacing: .8px;
text-transform: uppercase;
}
#${TD.APP_ID} .tdiary-search-wrap {
flex: 1;
display: flex;
justify-content: center;
}
#${TD.APP_ID} .tdiary-search {
width: min(460px, 100%);
height: 34px;
padding: 0 12px;
border: 1px solid #3d5967;
border-radius: 5px;
background: #111b20;
color: #e4edf1;
outline: none;
}
#${TD.APP_ID} .tdiary-close {
width: 34px;
height: 34px;
padding: 0;
border: 1px solid #455e6b;
border-radius: 5px;
background: #162228;
color: #aebdc4;
cursor: pointer;
font-size: 20px;
}
#${TD.APP_ID} .tdiary-sidebar {
grid-row: 2;
padding: 13px 9px;
overflow-y: auto;
border-right: 1px solid #394f59;
background: #141f24;
}
#${TD.APP_ID} .tdiary-tab {
width: 100%;
height: 42px;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
padding: 0 11px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: #aebdc5;
cursor: pointer;
font-weight: 700;
text-align: left;
}
#${TD.APP_ID} .tdiary-tab:hover {
background: #1b2a31;
color: #e5eef2;
}
#${TD.APP_ID} .tdiary-tab.tdiary-active {
border-color: #50778c;
background:
linear-gradient(
90deg,
#263d48,
#1b2c33
);
color: #f0f7fa;
box-shadow:
inset 3px 0 0 #76a9c3;
}
#${TD.APP_ID} .tdiary-tab-icon {
width: 22px;
text-align: center;
}
#${TD.APP_ID} .tdiary-list-pane {
grid-row: 2;
min-width: 0;
overflow: hidden;
border-right: 1px solid #394f59;
background: #1a282e;
}
#${TD.APP_ID} .tdiary-detail-pane {
grid-row: 2;
min-width: 0;
overflow-y: auto;
background: #202e34;
}
#${TD.APP_ID} .tdiary-pane-header {
height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
border-bottom: 1px solid #374d57;
background: #1e2c33;
}
#${TD.APP_ID} .tdiary-pane-header h2 {
margin: 0;
color: #eaf3f7;
font-size: 16px;
}
#${TD.APP_ID} .tdiary-primary,
#${TD.APP_ID} .tdiary-secondary,
#${TD.APP_ID} .tdiary-danger {
height: 32px;
padding: 0 12px;
border-radius: 5px;
cursor: pointer;
font-weight: 700;
}
#${TD.APP_ID} .tdiary-primary {
border: 1px solid #527d94;
background: #315f78;
color: #f2f8fb;
}
#${TD.APP_ID} .tdiary-secondary {
border: 1px solid #455f6c;
background: #23343d;
color: #c9d7dd;
}
#${TD.APP_ID} .tdiary-danger {
border: 1px solid #774247;
background: #4c292c;
color: #efd9da;
}
#${TD.APP_ID} .tdiary-list {
height: calc(100% - 54px);
overflow-y: auto;
padding: 10px;
}
#${TD.APP_ID} .tdiary-card {
width: 100%;
margin-bottom: 8px;
padding: 12px;
border: 1px solid #354d58;
border-radius: 6px;
background: #172329;
color: #c7d4da;
cursor: pointer;
text-align: left;
}
#${TD.APP_ID} .tdiary-card:hover {
border-color: #55798c;
background: #1c2b32;
}
#${TD.APP_ID} .tdiary-card.tdiary-selected {
border-color: #6d9ab2;
background: #1e3038;
box-shadow:
inset 3px 0 0 #79a9c1;
}
#${TD.APP_ID} .tdiary-card-top {
display: flex;
align-items: center;
gap: 7px;
}
#${TD.APP_ID} .tdiary-card-title {
flex: 1;
min-width: 0;
overflow: hidden;
color: #eaf2f5;
font-size: 14px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
#${TD.APP_ID} .tdiary-card-sub {
margin-top: 6px;
overflow: hidden;
color: #8da2ac;
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
#${TD.APP_ID} .tdiary-pin {
color: #d5b85e;
}
#${TD.APP_ID} .tdiary-status {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 0 7px;
border: 1px solid #4a6571;
border-radius: 999px;
background: #22333b;
color: #bacad2;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
white-space: nowrap;
}
#${TD.APP_ID} .tdiary-status.overdue,
#${TD.APP_ID} .tdiary-status.ended {
border-color: #795052;
background: #47292c;
color: #f0c7ca;
}
#${TD.APP_ID} .tdiary-status.due-soon,
#${TD.APP_ID} .tdiary-status.today,
#${TD.APP_ID} .tdiary-status.approaching {
border-color: #786a46;
background: #443b27;
color: #ebdca7;
}
#${TD.APP_ID} .tdiary-status.live,
#${TD.APP_ID} .tdiary-status.started,
#${TD.APP_ID} .tdiary-status.completed,
#${TD.APP_ID} .tdiary-status.obtained {
border-color: #47715e;
background: #274336;
color: #bfe4cf;
}
#${TD.APP_ID} .tdiary-detail {
min-height: 100%;
padding: 20px;
}
#${TD.APP_ID} .tdiary-empty {
min-height: 230px;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 28px;
color: #82969f;
text-align: center;
}
#${TD.APP_ID} .tdiary-empty-icon {
margin-bottom: 12px;
font-size: 36px;
}
#${TD.APP_ID} .tdiary-detail-title {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
#${TD.APP_ID} .tdiary-detail-title h2 {
flex: 1;
margin: 0;
color: #eef5f8;
font-size: 23px;
}
#${TD.APP_ID} .tdiary-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin: 14px 0 18px;
}
#${TD.APP_ID} .tdiary-info-grid {
display: grid;
grid-template-columns:
repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
#${TD.APP_ID} .tdiary-info-box {
padding: 11px;
border: 1px solid #3e5763;
border-radius: 6px;
background: #172329;
}
#${TD.APP_ID} .tdiary-info-box span {
display: block;
margin-bottom: 5px;
color: #849aa5;
font-size: 10px;
font-weight: 700;
letter-spacing: .7px;
text-transform: uppercase;
}
#${TD.APP_ID} .tdiary-info-box strong {
color: #e2ebef;
font-size: 13px;
}
#${TD.APP_ID} .tdiary-body,
#${TD.APP_ID} .tdiary-timeline {
padding: 14px;
border: 1px solid #3b535e;
border-radius: 6px;
background: #172329;
color: #c7d3d9;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
#${TD.APP_ID} a {
color: #7fb5d1;
}
#${TD.APP_ID} .tdiary-section-title {
margin: 18px 0 8px;
color: #dfe8ed;
font-size: 13px;
letter-spacing: .7px;
text-transform: uppercase;
}
#${TD.APP_ID} .tdiary-timeline-item {
position: relative;
padding: 0 0 16px 18px;
border-left: 1px solid #4a6876;
}
#${TD.APP_ID} .tdiary-timeline-item:last-child {
padding-bottom: 0;
}
#${TD.APP_ID} .tdiary-timeline-item::before {
content: '';
position: absolute;
top: 4px;
left: -5px;
width: 9px;
height: 9px;
border: 2px solid #172329;
border-radius: 50%;
background: #79a9c2;
}
#${TD.APP_ID} .tdiary-timeline-date {
margin-bottom: 4px;
color: #8399a4;
font-size: 11px;
}
#${TD.APP_ID} .tdiary-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
#${TD.APP_ID} .tdiary-tag {
padding: 3px 7px;
border: 1px solid #435f6c;
border-radius: 999px;
background: #1e3038;
color: #9fb3bd;
font-size: 11px;
}
#${TD.APP_ID} .tdiary-progress {
height: 11px;
margin-top: 9px;
overflow: hidden;
border: 1px solid #344c57;
border-radius: 999px;
background: #0f181c;
}
#${TD.APP_ID} .tdiary-progress span {
display: block;
height: 100%;
background:
linear-gradient(
90deg,
#416f88,
#76a7c0
);
}
#${TD.APP_ID} .tdiary-footer {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 13px;
border-top: 1px solid #354b55;
background: #121b20;
color: #728791;
font-size: 10px;
}
#${TD.APP_ID} .tdiary-modal-layer {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(4,8,10,.8);
z-index: 20;
}
#${TD.APP_ID} .tdiary-modal-layer.tdiary-show {
display: flex;
}
#${TD.APP_ID} .tdiary-modal {
width: min(620px, 96vw);
max-height: 88vh;
overflow-y: auto;
border: 1px solid #537185;
border-radius: 8px;
background: #1b292f;
color: #dce5ea;
box-shadow:
0 20px 60px rgba(0,0,0,.68);
}
#${TD.APP_ID} .tdiary-modal-header {
min-height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
border-bottom: 1px solid #3d5561;
background: #22343c;
}
#${TD.APP_ID} .tdiary-modal-header h3 {
margin: 0;
font-size: 17px;
}
#${TD.APP_ID} .tdiary-modal-body {
padding: 15px;
}
#${TD.APP_ID} .tdiary-form-grid {
display: grid;
grid-template-columns:
repeat(2, minmax(0, 1fr));
gap: 12px;
}
#${TD.APP_ID} .tdiary-field.full {
grid-column: 1 / -1;
}
#${TD.APP_ID} .tdiary-field label {
display: block;
margin-bottom: 5px;
color: #9eb0b9;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
#${TD.APP_ID} input,
#${TD.APP_ID} textarea,
#${TD.APP_ID} select {
width: 100%;
border: 1px solid #405a67;
border-radius: 5px;
background: #111b20;
color: #e3ebef;
outline: none;
}
#${TD.APP_ID} input,
#${TD.APP_ID} select {
height: 36px;
padding: 0 10px;
}
#${TD.APP_ID} textarea {
min-height: 110px;
padding: 10px;
resize: vertical;
}
#${TD.APP_ID} .tdiary-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 0 15px 15px;
}
#${TD.APP_ID} .tdiary-settings-card {
margin-bottom: 12px;
padding: 14px;
border: 1px solid #3a525e;
border-radius: 6px;
background: #172329;
}
#${TD.APP_ID} .tdiary-settings-card h3 {
margin: 0 0 10px;
color: #e5eef2;
font-size: 14px;
}
@media (max-width: 900px) {
#${TD.APP_ID} {
padding: 8px;
}
#${TD.APP_ID} .tdiary-shell {
width: 100%;
height: 96vh;
grid-template-columns:
70px
minmax(250px, .8fr)
minmax(320px, 1.2fr);
}
#${TD.APP_ID} .tdiary-tab {
justify-content: center;
padding: 0;
}
#${TD.APP_ID} .tdiary-tab-label {
display: none;
}
}
`;
document.head.appendChild(style);
}
function createLauncher() {
if (
document.getElementById(TD.LAUNCHER_ID)
) {
return;
}
const launcher = document.createElement('button');
launcher.id = TD.LAUNCHER_ID;
launcher.type = 'button';
launcher.title = 'Open Torn Diary';
launcher.innerHTML = `
<span>📘</span>
<span
class="tdiary-badge"
hidden
>0</span>
`;
document.body.appendChild(launcher);
restoreLauncherPosition(launcher);
bindLauncherDrag(launcher);
updateBadge();
}
function restoreLauncherPosition(launcher) {
const position = loadJSON(
TD.LAUNCHER_POSITION_KEY,
null
);
if (
!position ||
!Number.isFinite(position.left) ||
!Number.isFinite(position.top)
) {
return;
}
const maxLeft = Math.max(
0,
window.innerWidth - launcher.offsetWidth
);
const maxTop = Math.max(
0,
window.innerHeight - launcher.offsetHeight
);
launcher.style.left =
`${Math.min(Math.max(0, position.left), maxLeft)}px`;
launcher.style.top =
`${Math.min(Math.max(0, position.top), maxTop)}px`;
launcher.style.right = 'auto';
}
function saveLauncherPosition(launcher) {
localStorage.setItem(
TD.LAUNCHER_POSITION_KEY,
JSON.stringify({
left: parseFloat(launcher.style.left) || 0,
top: parseFloat(launcher.style.top) || 0
})
);
}
function bindLauncherDrag(launcher) {
let dragging = false;
let moved = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
launcher.addEventListener(
'pointerdown',
event => {
if (event.button !== 0) {
return;
}
const rect =
launcher.getBoundingClientRect();
dragging = true;
moved = false;
startX = event.clientX;
startY = event.clientY;
startLeft = rect.left;
startTop = rect.top;
launcher.style.left =
`${rect.left}px`;
launcher.style.top =
`${rect.top}px`;
launcher.style.right = 'auto';
launcher.classList.add(
'tdiary-dragging'
);
launcher.setPointerCapture(
event.pointerId
);
}
);
launcher.addEventListener(
'pointermove',
event => {
if (!dragging) {
return;
}
const deltaX =
event.clientX - startX;
const deltaY =
event.clientY - startY;
if (
Math.abs(deltaX) > 3 ||
Math.abs(deltaY) > 3
) {
moved = true;
}
const maxLeft = Math.max(
0,
window.innerWidth -
launcher.offsetWidth
);
const maxTop = Math.max(
0,
window.innerHeight -
launcher.offsetHeight
);
const left = Math.min(
Math.max(0, startLeft + deltaX),
maxLeft
);
const top = Math.min(
Math.max(0, startTop + deltaY),
maxTop
);
launcher.style.left = `${left}px`;
launcher.style.top = `${top}px`;
}
);
launcher.addEventListener(
'pointerup',
event => {
if (!dragging) {
return;
}
dragging = false;
launcher.classList.remove(
'tdiary-dragging'
);
if (
launcher.hasPointerCapture(
event.pointerId
)
) {
launcher.releasePointerCapture(
event.pointerId
);
}
if (moved) {
saveLauncherPosition(launcher);
return;
}
openApp();
}
);
launcher.addEventListener(
'pointercancel',
event => {
dragging = false;
launcher.classList.remove(
'tdiary-dragging'
);
if (
launcher.hasPointerCapture(
event.pointerId
)
) {
launcher.releasePointerCapture(
event.pointerId
);
}
}
);
}
function updateBadge() {
const badge = document.querySelector(
`#${TD.LAUNCHER_ID} .tdiary-badge`
);
if (!badge) {
return;
}
const count = state.entries.filter(entry => {
return (
entry.type === 'reminder' &&
['due-soon', 'overdue'].includes(
getReminderStatus(entry)
)
);
}).length;
badge.textContent = String(count);
badge.hidden = count === 0;
}
function createApp() {
if (document.getElementById(TD.APP_ID)) {
return;
}
const app = document.createElement('div');
app.id = TD.APP_ID;
app.innerHTML = `
<section class="tdiary-shell">
<header class="tdiary-header">
<div class="tdiary-logo">📘</div>
<div class="tdiary-brand">
<strong>Torn Diary</strong>
<span>Private Player Organiser</span>
</div>
<div class="tdiary-search-wrap">
<input
class="tdiary-search"
type="search"
placeholder="Search this tab..."
>
</div>
<button
class="tdiary-close"
type="button"
>×</button>
</header>
<aside class="tdiary-sidebar"></aside>
<section class="tdiary-list-pane">
<div class="tdiary-pane-header">
<h2 class="tdiary-list-title"></h2>
<button
class="tdiary-primary tdiary-add"
type="button"
>
+ New Entry
</button>
</div>
<div class="tdiary-list"></div>
</section>
<section class="tdiary-detail-pane">
<div class="tdiary-detail"></div>
</section>
<footer class="tdiary-footer">
<span>Private local storage</span>
<span class="tdiary-entry-count">
0 entries
</span>
</footer>
<div class="tdiary-modal-layer"></div>
</section>
`;
document.body.appendChild(app);
bindApp();
renderSidebar();
renderView();
}
function bindApp() {
const app = document.getElementById(TD.APP_ID);
app.querySelector('.tdiary-close')
.addEventListener(
'click',
closeApp
);
app.querySelector('.tdiary-search')
.addEventListener(
'input',
event => {
state.search = event.target.value;
state.selectedId = null;
renderView();
}
);
app.querySelector('.tdiary-add')
.addEventListener(
'click',
() => {
const type =
TABS[state.activeTab]?.type;
if (type) {
openEntryModal(type);
}
}
);
app.addEventListener(
'click',
event => {
if (event.target === app) {
closeApp();
}
}
);
}
function openApp() {
createApp();
const app =
document.getElementById(TD.APP_ID);
app.classList.add('tdiary-open');
document.body.style.overflow = 'hidden';
renderView();
clearInterval(state.timer);
state.timer = setInterval(() => {
updateBadge();
if (
app.classList.contains('tdiary-open')
) {
renderView();
}
}, 60000);
}
function closeApp() {
const app =
document.getElementById(TD.APP_ID);
if (!app) {
return;
}
closeModal();
app.classList.remove('tdiary-open');
document.body.style.overflow = '';
clearInterval(state.timer);
state.timer = null;
}
function renderSidebar() {
const sidebar = document.querySelector(
`#${TD.APP_ID} .tdiary-sidebar`
);
sidebar.innerHTML = Object.entries(TABS)
.map(([key, tab]) => `
<button
class="tdiary-tab ${
state.activeTab === key
? 'tdiary-active'
: ''
}"
type="button"
data-tab="${key}"
>
<span class="tdiary-tab-icon">
${tab.icon}
</span>
<span class="tdiary-tab-label">
${escapeHTML(tab.label)}
</span>
</button>
`)
.join('');
sidebar.querySelectorAll('.tdiary-tab')
.forEach(button => {
button.addEventListener(
'click',
() => {
state.activeTab =
button.dataset.tab;
state.selectedId = null;
state.search = '';
const search = document.querySelector(
`#${TD.APP_ID} .tdiary-search`
);
search.value = '';
renderSidebar();
renderView();
}
);
});
}
function renderView() {
const app =
document.getElementById(TD.APP_ID);
if (!app) {
return;
}
const tab = TABS[state.activeTab];
app.querySelector('.tdiary-list-title')
.textContent = tab.label;
app.querySelector('.tdiary-add')
.hidden =
state.activeTab === 'settings';
app.querySelector('.tdiary-search-wrap')
.style.visibility =
state.activeTab === 'settings'
? 'hidden'
: 'visible';
if (state.activeTab === 'settings') {
renderSettings();
updateEntryCount();
return;
}
renderList();
renderDetail();
updateEntryCount();
}
function updateEntryCount() {
const element = document.querySelector(
`#${TD.APP_ID} .tdiary-entry-count`
);
if (!element) {
return;
}
element.textContent =
`${state.entries.length} ${
state.entries.length === 1
? 'entry'
: 'entries'
}`;
}
function renderList() {
const list = document.querySelector(
`#${TD.APP_ID} .tdiary-list`
);
const entries = getVisibleEntries();
if (!entries.length) {
list.innerHTML = `
<div class="tdiary-empty">
<div class="tdiary-empty-icon">
${TABS[state.activeTab].icon}
</div>
<strong>
No ${escapeHTML(
TABS[state.activeTab]
.label
.toLowerCase()
)} yet
</strong>
<span style="margin-top:6px;">
Use + New Entry to add one.
</span>
</div>
`;
return;
}
list.innerHTML =
entries.map(renderCard).join('');
list.querySelectorAll('.tdiary-card')
.forEach(card => {
card.addEventListener(
'click',
() => {
state.selectedId =
card.dataset.id;
renderList();
renderDetail();
}
);
});
}
function renderCard(entry) {
const title =
entry.name ||
entry.itemName ||
entry.title ||
'Untitled';
let subtitle = '';
let status = '';
if (entry.type === 'player') {
subtitle =
entry.updates?.at(-1)?.text ||
entry.note ||
'No notes yet';
status =
`${entry.updates?.length || 0} notes`;
}
if (entry.type === 'item') {
const wanted =
Number(entry.quantityWanted || 0);
const have =
Number(entry.quantityHave || 0);
subtitle =
`${have} / ${wanted} obtained`;
status =
wanted > 0 && have >= wanted
? 'obtained'
: entry.priority || 'normal';
}
if (entry.type === 'note') {
subtitle =
entry.updates?.at(-1)?.text ||
entry.body ||
'No content';
status =
`${entry.updates?.length || 0} updates`;
}
if (entry.type === 'reminder') {
subtitle =
entry.note ||
formatDate(entry.reminderAt);
status = getReminderStatus(entry);
}
if (
entry.type === 'faction' ||
entry.type === 'torn'
) {
subtitle =
entry.note ||
formatCountdown(entry.startAt);
status = getEventStatus(entry);
}
return `
<button
class="tdiary-card ${
state.selectedId === entry.id
? 'tdiary-selected'
: ''
}"
type="button"
data-id="${entry.id}"
>
<div class="tdiary-card-top">
${
entry.pinned
? '<span class="tdiary-pin">★</span>'
: ''
}
<span class="tdiary-card-title">
${escapeHTML(title)}
</span>
<span class="tdiary-status ${escapeHTML(status)}">
${escapeHTML(status)}
</span>
</div>
<div class="tdiary-card-sub">
${escapeHTML(subtitle)}
</div>
</button>
`;
}
function renderDetail() {
const detail = document.querySelector(
`#${TD.APP_ID} .tdiary-detail`
);
let entry = getEntry(state.selectedId);
if (!entry) {
entry = getVisibleEntries()[0] || null;
state.selectedId =
entry?.id || null;
if (entry) {
renderList();
}
}
if (!entry) {
detail.innerHTML = `
<div class="tdiary-empty">
<div class="tdiary-empty-icon">
${TABS[state.activeTab].icon}
</div>
<strong>
Select an entry to view its details.
</strong>
</div>
`;
return;
}
detail.innerHTML =
renderEntryDetail(entry);
bindDetailActions(entry);
}
function renderEntryDetail(entry) {
const title =
entry.name ||
entry.itemName ||
entry.title ||
'Untitled';
let html = `
<div class="tdiary-detail-title">
<h2>${escapeHTML(title)}</h2>
${
entry.pinned
? '<span class="tdiary-pin">★</span>'
: ''
}
</div>
<div class="tdiary-actions">
<button
class="tdiary-secondary"
data-action="edit"
>
Edit
</button>
<button
class="tdiary-secondary"
data-action="pin"
>
${entry.pinned ? 'Unpin' : 'Pin'}
</button>
${
['player', 'note'].includes(entry.type)
? `
<button
class="tdiary-primary"
data-action="update"
>
+ Add Update
</button>
`
: ''
}
${
entry.type === 'reminder' &&
!entry.completed
? `
<button
class="tdiary-primary"
data-action="complete"
>
Complete
</button>
`
: ''
}
<button
class="tdiary-danger"
data-action="delete"
>
Delete
</button>
</div>
`;
if (entry.type === 'player') {
html += `
<div class="tdiary-info-grid">
<div class="tdiary-info-box">
<span>Profile</span>
<strong>
${
entry.profileUrl
? `
<a
href="${escapeHTML(entry.profileUrl)}"
target="_blank"
rel="noopener noreferrer"
>
Open Torn Profile
</a>
`
: 'Not set'
}
</strong>
</div>
<div class="tdiary-info-box">
<span>Added</span>
<strong>
${escapeHTML(
formatDate(entry.createdAt)
)}
</strong>
</div>
</div>
${
entry.note
? `
<div class="tdiary-body">
${linkify(entry.note)}
</div>
`
: ''
}
${renderTimeline(entry)}
`;
}
if (entry.type === 'item') {
const wanted =
Math.max(
0,
Number(entry.quantityWanted || 0)
);
const have =
Math.max(
0,
Number(entry.quantityHave || 0)
);
const percentage =
wanted > 0
? Math.min(
100,
Math.round(
(have / wanted) * 100
)
)
: 0;
html += `
<div class="tdiary-info-grid">
<div class="tdiary-info-box">
<span>Progress</span>
<strong>
${have} / ${wanted}
</strong>
<div class="tdiary-progress">
<span
style="width:${percentage}%"
></span>
</div>
</div>
<div class="tdiary-info-box">
<span>Priority</span>
<strong>
${escapeHTML(
entry.priority || 'normal'
)}
</strong>
</div>
<div class="tdiary-info-box">
<span>Added</span>
<strong>
${escapeHTML(
formatDate(entry.createdAt)
)}
</strong>
</div>
<div class="tdiary-info-box">
<span>Item Link</span>
<strong>
${
entry.itemUrl
? `
<a
href="${escapeHTML(entry.itemUrl)}"
target="_blank"
rel="noopener noreferrer"
>
Open Link
</a>
`
: 'Not set'
}
</strong>
</div>
</div>
<div class="tdiary-actions">
<button
class="tdiary-secondary"
data-action="decrease"
>
− 1
</button>
<button
class="tdiary-secondary"
data-action="increase"
>
+ 1
</button>
</div>
${
entry.note
? `
<div class="tdiary-body">
${linkify(entry.note)}
</div>
`
: ''
}
`;
}
if (entry.type === 'note') {
html += `
<div class="tdiary-info-grid">
<div class="tdiary-info-box">
<span>Created</span>
<strong>
${escapeHTML(
formatDate(entry.createdAt)
)}
</strong>
</div>
<div class="tdiary-info-box">
<span>Last Updated</span>
<strong>
${escapeHTML(
formatDate(entry.updatedAt)
)}
</strong>
</div>
</div>
${
entry.body
? `
<div class="tdiary-body">
${linkify(entry.body)}
</div>
`
: ''
}
${renderTimeline(entry)}
`;
}
if (entry.type === 'reminder') {
const status =
getReminderStatus(entry);
html += `
<div class="tdiary-info-grid">
<div class="tdiary-info-box">
<span>Status</span>
<strong>
<span class="tdiary-status ${status}">
${escapeHTML(status)}
</span>
</strong>
</div>
<div class="tdiary-info-box">
<span>Due</span>
<strong>
${escapeHTML(
formatDate(entry.reminderAt)
)}
</strong>
</div>
<div class="tdiary-info-box">
<span>Countdown</span>
<strong>
${escapeHTML(
formatCountdown(
entry.reminderAt
)
)}
</strong>
</div>
<div class="tdiary-info-box">
<span>Completed</span>
<strong>
${
entry.completedAt
? escapeHTML(
formatDate(
entry.completedAt
)
)
: 'No'
}
</strong>
</div>
</div>
${
entry.link
? `
<div class="tdiary-body">
<a
href="${escapeHTML(entry.link)}"
target="_blank"
rel="noopener noreferrer"
>
Open Reminder Link
</a>
</div>
`
: ''
}
${
entry.note
? `
<div class="tdiary-section-title">
Reminder Notes
</div>
<div class="tdiary-body">
${linkify(entry.note)}
</div>
`
: ''
}
`;
}
if (
entry.type === 'faction' ||
entry.type === 'torn'
) {
const status =
getEventStatus(entry);
html += `
<div class="tdiary-info-grid">
<div class="tdiary-info-box">
<span>Status</span>
<strong>
<span class="tdiary-status ${status}">
${escapeHTML(status)}
</span>
</strong>
</div>
<div class="tdiary-info-box">
<span>Starts</span>
<strong>
${escapeHTML(
formatDate(entry.startAt)
)}
</strong>
</div>
<div class="tdiary-info-box">
<span>Countdown</span>
<strong>
${escapeHTML(
formatCountdown(entry.startAt)
)}
</strong>
</div>
<div class="tdiary-info-box">
<span>Ends</span>
<strong>
${escapeHTML(
formatDate(entry.endAt)
)}
</strong>
</div>
</div>
${
entry.link
? `
<div class="tdiary-body">
<a
href="${escapeHTML(entry.link)}"
target="_blank"
rel="noopener noreferrer"
>
Open Event Link
</a>
</div>
`
: ''
}
${
entry.note
? `
<div class="tdiary-section-title">
Event Notes
</div>
<div class="tdiary-body">
${linkify(entry.note)}
</div>
`
: ''
}
`;
}
if (entry.tags?.length) {
html += `
<div class="tdiary-tags">
${entry.tags
.map(
tag => `
<span class="tdiary-tag">
#${escapeHTML(tag)}
</span>
`
)
.join('')}
</div>
`;
}
return html;
}
function renderTimeline(entry) {
const updates =
Array.isArray(entry.updates)
? [...entry.updates].reverse()
: [];
if (!updates.length) {
return `
<div class="tdiary-section-title">
Timeline
</div>
<div class="tdiary-body">
No timestamped updates yet.
</div>
`;
}
return `
<div class="tdiary-section-title">
Timeline
</div>
<div class="tdiary-timeline">
${updates
.map(
update => `
<div class="tdiary-timeline-item">
<div class="tdiary-timeline-date">
${escapeHTML(
formatDate(
update.createdAt
)
)}
</div>
<div>
${linkify(update.text)}
</div>
</div>
`
)
.join('')}
</div>
`;
}
function bindDetailActions(entry) {
const detail = document.querySelector(
`#${TD.APP_ID} .tdiary-detail`
);
detail.querySelector('[data-action="edit"]')
?.addEventListener(
'click',
() => openEntryModal(
entry.type,
entry
)
);
detail.querySelector('[data-action="pin"]')
?.addEventListener(
'click',
() => {
entry.pinned = !entry.pinned;
entry.updatedAt = nowISO();
saveEntries();
renderView();
}
);
detail.querySelector('[data-action="update"]')
?.addEventListener(
'click',
() => openUpdateModal(entry)
);
detail.querySelector('[data-action="complete"]')
?.addEventListener(
'click',
() => {
entry.completed = true;
entry.completedAt = nowISO();
entry.updatedAt = nowISO();
saveEntries();
renderView();
}
);
detail.querySelector('[data-action="increase"]')
?.addEventListener(
'click',
() => {
entry.quantityHave =
Number(
entry.quantityHave || 0
) + 1;
entry.updatedAt = nowISO();
saveEntries();
renderView();
}
);
detail.querySelector('[data-action="decrease"]')
?.addEventListener(
'click',
() => {
entry.quantityHave = Math.max(
0,
Number(
entry.quantityHave || 0
) - 1
);
entry.updatedAt = nowISO();
saveEntries();
renderView();
}
);
detail.querySelector('[data-action="delete"]')
?.addEventListener(
'click',
() => {
if (
!confirm(
'Delete this Torn Diary entry?'
)
) {
return;
}
state.entries =
state.entries.filter(
item => item.id !== entry.id
);
state.selectedId = null;
saveEntries();
renderView();
}
);
}
function toLocalInputValue(value) {
const date = parseDate(value);
if (!date) {
return '';
}
const local = new Date(
date.getTime() -
date.getTimezoneOffset() * 60000
);
return local
.toISOString()
.slice(0, 16);
}
function normaliseTags(value) {
return String(value || '')
.split(',')
.map(tag =>
tag.trim().replace(/^#/, '')
)
.filter(Boolean);
}
function getFormFields(type, entry = {}) {
const tags =
(entry.tags || []).join(', ');
const tagField = `
<div class="tdiary-field full">
<label>Tags</label>
<input
name="tags"
type="text"
value="${escapeHTML(tags)}"
placeholder="education, targets, frostbite"
>
</div>
`;
if (type === 'player') {
return `
<div class="tdiary-field">
<label>Player ID</label>
<input
name="playerId"
inputmode="numeric"
value="${escapeHTML(
entry.playerId || ''
)}"
>
</div>
<div class="tdiary-field">
<label>API Lookup</label>
<button
class="tdiary-secondary"
type="button"
data-player-lookup
style="width:100%;height:36px;"
>
Lookup Player
</button>
</div>
<div class="tdiary-field">
<label>Player Name</label>
<input
name="name"
required
value="${escapeHTML(
entry.name || ''
)}"
>
</div>
<div class="tdiary-field">
<label>Profile Link</label>
<input
name="profileUrl"
type="url"
value="${escapeHTML(
entry.profileUrl || ''
)}"
>
</div>
<div class="tdiary-field full">
<label>General Note</label>
<textarea name="note">${escapeHTML(
entry.note || ''
)}</textarea>
</div>
${tagField}
`;
}
if (type === 'item') {
return `
<div class="tdiary-field full">
<label>Item Name</label>
<input
name="itemName"
required
value="${escapeHTML(
entry.itemName || ''
)}"
>
</div>
<div class="tdiary-field">
<label>Quantity Wanted</label>
<input
name="quantityWanted"
type="number"
min="0"
value="${
Number(
entry.quantityWanted || 1
)
}"
>
</div>
<div class="tdiary-field">
<label>Quantity Have</label>
<input
name="quantityHave"
type="number"
min="0"
value="${
Number(
entry.quantityHave || 0
)
}"
>
</div>
<div class="tdiary-field">
<label>Priority</label>
<select name="priority">
<option value="low" ${
entry.priority === 'low'
? 'selected'
: ''
}>Low</option>
<option value="normal" ${
!entry.priority ||
entry.priority === 'normal'
? 'selected'
: ''
}>Normal</option>
<option value="high" ${
entry.priority === 'high'
? 'selected'
: ''
}>High</option>
</select>
</div>
<div class="tdiary-field">
<label>Item Link</label>
<input
name="itemUrl"
type="url"
value="${escapeHTML(
entry.itemUrl || ''
)}"
>
</div>
<div class="tdiary-field full">
<label>Notes</label>
<textarea name="note">${escapeHTML(
entry.note || ''
)}</textarea>
</div>
${tagField}
`;
}
if (type === 'note') {
return `
<div class="tdiary-field full">
<label>Note Title</label>
<input
name="title"
required
value="${escapeHTML(
entry.title || ''
)}"
>
</div>
<div class="tdiary-field full">
<label>Note</label>
<textarea
name="body"
required
>${escapeHTML(
entry.body || ''
)}</textarea>
</div>
${tagField}
`;
}
if (type === 'reminder') {
return `
<div class="tdiary-field full">
<label>Reminder Title</label>
<input
name="title"
required
value="${escapeHTML(
entry.title || ''
)}"
>
</div>
<div class="tdiary-field">
<label>Date and Time</label>
<input
name="reminderAt"
type="datetime-local"
required
value="${toLocalInputValue(
entry.reminderAt
)}"
>
</div>
<div class="tdiary-field">
<label>Optional Link</label>
<input
name="link"
type="url"
value="${escapeHTML(
entry.link || ''
)}"
>
</div>
<div class="tdiary-field full">
<label>Notes</label>
<textarea name="note">${escapeHTML(
entry.note || ''
)}</textarea>
</div>
${tagField}
`;
}
return `
<div class="tdiary-field full">
<label>Event Title</label>
<input
name="title"
required
value="${escapeHTML(
entry.title || ''
)}"
>
</div>
<div class="tdiary-field">
<label>Start Date and Time</label>
<input
name="startAt"
type="datetime-local"
required
value="${toLocalInputValue(
entry.startAt
)}"
>
</div>
<div class="tdiary-field">
<label>End Date and Time</label>
<input
name="endAt"
type="datetime-local"
value="${toLocalInputValue(
entry.endAt
)}"
>
</div>
<div class="tdiary-field full">
<label>Optional Link</label>
<input
name="link"
type="url"
value="${escapeHTML(
entry.link || ''
)}"
>
</div>
<div class="tdiary-field full">
<label>Event Notes</label>
<textarea name="note">${escapeHTML(
entry.note || ''
)}</textarea>
</div>
${tagField}
`;
}
function openEntryModal(
type,
existingEntry = null
) {
const layer = document.querySelector(
`#${TD.APP_ID} .tdiary-modal-layer`
);
const editing = Boolean(existingEntry);
layer.innerHTML = `
<form class="tdiary-modal">
<div class="tdiary-modal-header">
<h3>
${editing ? 'Edit Entry' : 'New Entry'}
</h3>
<button
class="tdiary-close"
type="button"
data-close-modal
>×</button>
</div>
<div class="tdiary-modal-body">
<div class="tdiary-form-grid">
${getFormFields(
type,
existingEntry || {}
)}
</div>
</div>
<div class="tdiary-modal-actions">
<button
class="tdiary-secondary"
type="button"
data-close-modal
>
Cancel
</button>
<button
class="tdiary-primary"
type="submit"
>
${editing
? 'Save Changes'
: 'Save Entry'
}
</button>
</div>
</form>
`;
layer.classList.add('tdiary-show');
layer.querySelectorAll(
'[data-close-modal]'
).forEach(button => {
button.addEventListener(
'click',
closeModal
);
});
layer.querySelector(
'[data-player-lookup]'
)?.addEventListener(
'click',
async event => {
const button = event.currentTarget;
const form = layer.querySelector('form');
const playerId = String(
form.elements.playerId?.value || ''
).trim();
if (!playerId) {
alert(
'Enter a Torn player ID first.'
);
return;
}
button.disabled = true;
button.textContent = 'Looking Up...';
try {
const data = await tornApiRequest(
`/user/${encodeURIComponent(playerId)}/basic?striptags=true`
);
const profile = data?.profile;
if (
!profile ||
profile.id == null ||
!profile.name
) {
throw new Error(
'Torn API returned no player profile.'
);
}
form.elements.playerId.value =
String(profile.id);
form.elements.name.value =
String(profile.name);
form.elements.profileUrl.value =
`https://www.torn.com/profiles.php?XID=${profile.id}`;
} catch (error) {
console.error(
'[Torn Diary]',
error
);
alert(error.message);
} finally {
button.disabled = false;
button.textContent = 'Lookup Player';
}
}
);
layer.querySelector('form')
.addEventListener(
'submit',
event => {
event.preventDefault();
saveEntryFromForm(
type,
new FormData(event.currentTarget),
existingEntry
);
}
);
}
function saveEntryFromForm(
type,
formData,
existingEntry
) {
const timestamp = nowISO();
const entry =
existingEntry || {
id: createId(),
type,
createdAt: timestamp,
updatedAt: timestamp,
updates: [],
pinned: false
};
entry.updatedAt = timestamp;
entry.tags = normaliseTags(
formData.get('tags')
);
if (type === 'player') {
entry.playerId = String(
formData.get('playerId') || ''
).trim();
entry.name = String(
formData.get('name') || ''
).trim();
entry.profileUrl = String(
formData.get('profileUrl') || ''
).trim();
entry.note = String(
formData.get('note') || ''
).trim();
}
if (type === 'item') {
entry.itemName = String(
formData.get('itemName') || ''
).trim();
entry.quantityWanted = Math.max(
0,
Number(
formData.get('quantityWanted') || 0
)
);
entry.quantityHave = Math.max(
0,
Number(
formData.get('quantityHave') || 0
)
);
entry.priority = String(
formData.get('priority') || 'normal'
);
entry.itemUrl = String(
formData.get('itemUrl') || ''
).trim();
entry.note = String(
formData.get('note') || ''
).trim();
}
if (type === 'note') {
entry.title = String(
formData.get('title') || ''
).trim();
entry.body = String(
formData.get('body') || ''
).trim();
}
if (type === 'reminder') {
entry.title = String(
formData.get('title') || ''
).trim();
entry.reminderAt = new Date(
formData.get('reminderAt')
).toISOString();
entry.link = String(
formData.get('link') || ''
).trim();
entry.note = String(
formData.get('note') || ''
).trim();
if (!existingEntry) {
entry.completed = false;
entry.completedAt = null;
}
}
if (
type === 'faction' ||
type === 'torn'
) {
entry.title = String(
formData.get('title') || ''
).trim();
entry.startAt = new Date(
formData.get('startAt')
).toISOString();
const endAt =
formData.get('endAt');
entry.endAt = endAt
? new Date(endAt).toISOString()
: '';
entry.link = String(
formData.get('link') || ''
).trim();
entry.note = String(
formData.get('note') || ''
).trim();
}
if (!existingEntry) {
state.entries.push(entry);
}
state.selectedId = entry.id;
saveEntries();
closeModal();
renderView();
}
function openUpdateModal(entry) {
const layer = document.querySelector(
`#${TD.APP_ID} .tdiary-modal-layer`
);
layer.innerHTML = `
<form class="tdiary-modal">
<div class="tdiary-modal-header">
<h3>Add Timestamped Update</h3>
<button
class="tdiary-close"
type="button"
data-close-modal
>×</button>
</div>
<div class="tdiary-modal-body">
<div class="tdiary-field">
<label>Update</label>
<textarea
name="updateText"
required
></textarea>
</div>
</div>
<div class="tdiary-modal-actions">
<button
class="tdiary-secondary"
type="button"
data-close-modal
>
Cancel
</button>
<button
class="tdiary-primary"
type="submit"
>
Add Update
</button>
</div>
</form>
`;
layer.classList.add('tdiary-show');
layer.querySelectorAll(
'[data-close-modal]'
).forEach(button => {
button.addEventListener(
'click',
closeModal
);
});
layer.querySelector('form')
.addEventListener(
'submit',
event => {
event.preventDefault();
const text = String(
new FormData(event.currentTarget)
.get('updateText') || ''
).trim();
if (!text) {
return;
}
entry.updates =
Array.isArray(entry.updates)
? entry.updates
: [];
entry.updates.push({
id: createId(),
text,
createdAt: nowISO()
});
entry.updatedAt = nowISO();
saveEntries();
closeModal();
renderView();
}
);
}
function closeModal() {
const layer = document.querySelector(
`#${TD.APP_ID} .tdiary-modal-layer`
);
if (!layer) {
return;
}
layer.classList.remove('tdiary-show');
layer.innerHTML = '';
}
function renderSettings() {
const list = document.querySelector(
`#${TD.APP_ID} .tdiary-list`
);
const detail = document.querySelector(
`#${TD.APP_ID} .tdiary-detail`
);
list.innerHTML = `
<div class="tdiary-empty">
<div class="tdiary-empty-icon">
⚙️
</div>
<strong>
Torn Diary Settings
</strong>
</div>
`;
detail.innerHTML = `
<div class="tdiary-settings-card">
<h3>General</h3>
<div class="tdiary-form-grid">
<div class="tdiary-field">
<label>Starting Tab</label>
<select id="tdiary-start-tab">
${Object.entries(TABS)
.filter(
([key]) =>
key !== 'settings'
)
.map(
([key, tab]) => `
<option
value="${key}"
${
state.settings.startTab === key
? 'selected'
: ''
}
>
${escapeHTML(tab.label)}
</option>
`
)
.join('')}
</select>
</div>
<div class="tdiary-field">
<label>Due Soon Threshold</label>
<select id="tdiary-due-hours">
${[6, 12, 24, 48, 72]
.map(
hours => `
<option
value="${hours}"
${
Number(
state.settings.dueSoonHours
) === hours
? 'selected'
: ''
}
>
${hours} hours
</option>
`
)
.join('')}
</select>
</div>
</div>
<div class="tdiary-actions">
<button
class="tdiary-primary"
data-save-settings
>
Save Settings
</button>
</div>
</div>
<div class="tdiary-settings-card">
<h3>Torn API</h3>
<div class="tdiary-field">
<label>API Key</label>
<input
id="tdiary-api-key"
type="password"
value="${escapeHTML(
state.settings.apiKey || ''
)}"
autocomplete="off"
>
</div>
</div>
<div class="tdiary-settings-card">
<h3>Backup</h3>
<div class="tdiary-actions">
<button
class="tdiary-secondary"
data-export
>
Export Diary
</button>
<button
class="tdiary-secondary"
data-import
>
Import Diary
</button>
<input
id="tdiary-import-file"
type="file"
accept=".json,application/json"
hidden
>
</div>
</div>
<div class="tdiary-settings-card">
<h3>Clear Diary</h3>
<button
class="tdiary-danger"
data-clear
>
Delete All Diary Data
</button>
</div>
`;
detail.querySelector(
'[data-save-settings]'
).addEventListener(
'click',
() => {
state.settings.startTab =
detail.querySelector(
'#tdiary-start-tab'
).value;
state.settings.dueSoonHours =
Number(
detail.querySelector(
'#tdiary-due-hours'
).value
);
state.settings.apiKey = String(
detail.querySelector(
'#tdiary-api-key'
).value || ''
).trim();
saveSettings();
updateBadge();
alert(
'Torn Diary settings saved.'
);
}
);
detail.querySelector('[data-export]')
.addEventListener(
'click',
exportDiary
);
const importInput =
detail.querySelector(
'#tdiary-import-file'
);
detail.querySelector('[data-import]')
.addEventListener(
'click',
() => importInput.click()
);
importInput.addEventListener(
'change',
importDiary
);
detail.querySelector('[data-clear]')
.addEventListener(
'click',
() => {
if (
!confirm(
'Delete all Torn Diary data?'
)
) {
return;
}
state.entries = [];
state.selectedId = null;
saveEntries();
renderView();
}
);
}
function exportDiary() {
const data = {
application: 'Torn Diary',
version: 1,
exportedAt: nowISO(),
entries: state.entries,
settings: state.settings
};
const blob = new Blob(
[JSON.stringify(data, null, 2)],
{
type: 'application/json'
}
);
const url =
URL.createObjectURL(blob);
const link =
document.createElement('a');
link.href = url;
link.download =
`torn-diary-${new Date()
.toISOString()
.slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function importDiary(event) {
const file =
event.target.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const data =
JSON.parse(reader.result);
if (!Array.isArray(data.entries)) {
throw new Error(
'Invalid backup'
);
}
if (
!confirm(
'Replace current Torn Diary data with this backup?'
)
) {
return;
}
state.entries = data.entries;
state.settings = {
...DEFAULT_SETTINGS,
...(data.settings || {})
};
state.selectedId = null;
saveEntries();
saveSettings();
renderView();
alert(
'Torn Diary imported.'
);
} catch (error) {
console.error(
'[Torn Diary]',
error
);
alert(
'Invalid Torn Diary backup.'
);
} finally {
event.target.value = '';
}
};
reader.readAsText(file);
}
function initialise() {
loadData();
injectStyles();
createLauncher();
console.info(
'[Torn Diary] V1.1.1 loaded.'
);
}
setTimeout(
initialise,
TD.LOAD_DELAY
);
})();