-
❓ .gitignoreUnknown, 37 B⬇
-
❓ README.mdUnknown, 2.94 kB⬇
-
🌐 admin_panel.phpWeb, 1.54 kB⬇
-
🌐 api.jsWeb, 22.21 kB⬇
-
🌐 api.phpWeb, 9.62 kB⬇
-
🌐 app.jsWeb, 61.42 kB⬇
-
🌐 attachments.jsWeb, 7.30 kB⬇
-
🌐 base.cssWeb, 2.23 kB⬇
-
🌐 choices-editor.jsWeb, 28.11 kB⬇
-
🌐 choices.cssWeb, 1.14 kB⬇
-
🌐 content.jsWeb, 31.08 kB⬇
-
🌐 context.jsWeb, 9.26 kB⬇
-
🌐 conversation-index.jsWeb, 10.74 kB⬇
-
🌐 conversation.cssWeb, 16.33 kB⬇
-
🌐 conversation.jsWeb, 1.11 kB⬇
-
🌐 dialogs.cssWeb, 11.43 kB⬇
-
🌐 diction.jsWeb, 948 B⬇
-
🌐 document-index.jsWeb, 1.04 kB⬇
-
🌐 document-model.jsWeb, 12.88 kB⬇
-
🌐 document.cssWeb, 6.74 kB⬇
-
🌐 document.jsWeb, 44.96 kB⬇
-
🌐 dynamic.phpWeb, 533 B⬇
-
📷 favicon-lg.pngImage, 9.11 kB⬇
-
📷 favicon.pngImage, 3.12 kB⬇
-
🌐 index-lists.cssWeb, 4.77 kB⬇
-
🌐 index.phpWeb, 49.72 kB⬇
-
🌐 indexjs.jsWeb, 377 B⬇
-
🌐 instant-answer.cssWeb, 5.17 kB⬇
-
🌐 layout.cssWeb, 11.37 kB⬇
-
🌐 login.phpWeb, 997 B⬇
-
❓ manifest.webmanifestUnknown, 262 B⬇
-
🌐 memory.jsWeb, 407 B⬇
-
🌐 mermaid.cssWeb, 6.16 kB⬇
-
🌐 model-presets.jsonWeb, 5.51 kB⬇
-
🌐 models.jsonWeb, 8.05 kB⬇
-
🌐 notes.cssWeb, 11.97 kB⬇
-
🌐 overlays.cssWeb, 13.97 kB⬇
-
⚠️ php.iniOS File, 424 B⬇
-
🌐 rankings-view.jsWeb, 5.67 kB⬇
-
🌐 rankings.cssWeb, 3.35 kB⬇
-
🌐 scratchpad-conversation.jsWeb, 4.87 kB⬇
-
🌐 scratchpad-index.jsWeb, 15.55 kB⬇
-
🌐 scratchpad-model.jsWeb, 13.29 kB⬇
-
🌐 scratchpad.cssWeb, 3.12 kB⬇
-
🌐 settings.cssWeb, 4.65 kB⬇
-
🌐 shared-view.cssWeb, 3.48 kB⬇
-
🌐 shared.cssWeb, 16.26 kB⬇
-
🌐 shared.jsWeb, 14.63 kB⬇
-
🌐 shared.phpWeb, 3.28 kB⬇
-
🌐 sticky-notes-editor.jsWeb, 64.84 kB⬇
-
🌐 storage.jsWeb, 36.73 kB⬇
-
🌐 style.cssWeb, 12.06 kB⬇
-
🌐 sw.jsWeb, 1.69 kB⬇
-
🌐 sync.jsWeb, 19.02 kB⬇
-
🌐 tools-index.jsWeb, 15.00 kB⬇
-
🌐 users.jsWeb, 49.86 kB⬇
-
🌐 utility-models.jsonWeb, 1.04 kB⬇
-
🌐 voice-models.jsonWeb, 4.81 kB⬇
/**
* Class ScratchpadModel
* Pure data model for one scratchpad revision: a flat array of typed items
* (header, text, code, quote, link, list-step, diagram-mermaid, color-hex,
* table, equation) each carrying a stable guid, plus the table of contents
* entries referencing item guids. Owns normalization, serialization, and the
* structural mutations driven by the scratchpad editing tools. Item shapes
* follow the conversation content format so the existing renderers consume
* them directly.
*/
class ScratchpadModel {
static TYPES = ['header', 'text', 'code', 'quote', 'link', 'list-step', 'diagram-mermaid', 'color-hex', 'table', 'equation'];
#items = [];
#toc = [];
/**
* Initializes the model with the stored revision state.
* @param {Array} [items=[]] - The stored scratchpad items.
* @param {Array} [toc=[]] - The stored table of contents entries.
*/
constructor(items = [], toc = []) {
this.load(items, toc);
}
/**
* Loads the stored revision state, normalizing item and entry shapes and
* backfilling missing guids.
* @param {Array} [items=[]] - The stored scratchpad items.
* @param {Array} [toc=[]] - The stored table of contents entries.
*/
load(items = [], toc = []) {
this.#items = (Array.isArray(items) ? items : []).map(item => this.#normalize_item(item));
this.#toc = (Array.isArray(toc) ? toc : []).map(entry => this.#normalize_toc_entry(entry));
}
/**
* The live items array. Treat as read-only; mutations go through the
* model methods so serialization stays consistent.
* @returns {Array<Object>} The item array.
*/
get items() {
return this.#items;
}
/**
* The live table of contents entries. Treat as read-only.
* @returns {Array<Object>} The toc entry array.
*/
get toc() {
return this.#toc;
}
/**
* Serializes the model back into the stored revision shape.
* @returns {{items: Array<Object>, toc: Array<Object>}} The revision state.
*/
get_state() {
return {
items: this.#serialize_items(),
toc: this.#toc.map(entry => {
const out = { guid: entry.guid, target_guid: entry.target_guid, title: entry.title };
if (entry.description) out.description = entry.description;
return out;
})
};
}
/**
* Resolves an edit anchor guid to an item index, supporting the dynamic
* "last" and "first" values.
* @param {string} guid - The item guid or "last"/"first".
* @returns {number} The item index, or -1 when not found.
*/
resolve_index(guid) {
const key = String(guid || '').trim().toLowerCase();
if (key === 'last') return this.#items.length - 1;
if (key === 'first') return 0;
return this.#items.findIndex(item => item.guid === guid);
}
/**
* Builds a model item from an edit_scratchpad item payload, assigning a
* fresh guid. Ordered list steps number themselves implicitly unless the
* payload carries an explicit index.
* @param {Object} item - The tool item payload.
* @returns {Object|null} The model item, or null when the payload is invalid.
*/
tool_item(item) {
if (!item || typeof item.type !== 'string') return null;
const type = ScratchpadModel.canonical_type(item.type);
if (!type) return null;
const value = typeof item.value === 'string' ? item.value : '';
const hasRows = Array.isArray(item['table-rows']) && item['table-rows'].length > 0;
if (value === '' && !(type === 'table' && hasRows)) return null;
const built = { guid: this.#new_guid(), type: type, value: value };
if (item.caption !== undefined && item.caption !== null && String(item.caption) !== '') built.caption = String(item.caption);
if (type === 'header') {
built.index = (Number.isInteger(item.index) && item.index >= 1 && item.index <= 6) ? item.index : null;
}
if (type === 'list-step') {
built.ordered = !!item.ordered;
built.index = (built.ordered && Number.isInteger(item.index)) ? item.index : null;
}
if (type === 'code' && typeof item.language === 'string' && item.language) {
built.language = item.language;
}
if (type === 'table') {
built['has-header'] = !!item['has-header'];
built['table-rows'] = hasRows
? item['table-rows'].map(row => Array.isArray(row) ? row.map(cell => String(cell ?? '')) : [])
: [];
}
if (type === 'link' && Array.isArray(item.source) && item.source.length > 0) {
const source = item.source.find(src => src && typeof src.url === 'string' && src.url);
if (source) built.source = [{ title: String(source.title || source.url), url: source.url }];
}
return built;
}
/**
* Applies a normalized edit_scratchpad edit to the items: add (with an
* optional guid anchor, "last"/"first" dynamic anchors, appended at the
* end when absent), replace, move (with a target_guid anchor) and remove.
* Removing an item also removes the table of contents entries pointing at
* it.
* @param {Object} edit - The normalized edit { action, guid, target_guid, position, item }.
* @returns {Object|null} { guid, flashGuid, contentChanged } for the affected item, or null when the edit cannot apply.
*/
apply_edit(edit) {
const action = String(edit?.action || '');
const position = edit?.position === 'above' ? 'above' : 'below';
if (action === 'add') {
const item = this.tool_item(edit?.item);
if (!item) return null;
const anchor = this.resolve_index(edit?.guid);
const at = anchor === -1 ? this.#items.length : (position === 'above' ? anchor : anchor + 1);
this.#items.splice(at, 0, item);
return { guid: item.guid, flashGuid: item.guid, contentChanged: true };
}
const index = this.resolve_index(edit?.guid);
if (index === -1) return null;
if (action === 'replace') {
const item = this.tool_item(edit?.item);
if (!item) return null;
item.guid = this.#items[index].guid;
this.#items[index] = item;
return { guid: item.guid, flashGuid: item.guid, contentChanged: true };
}
if (action === 'remove') {
const [removed] = this.#items.splice(index, 1);
this.#toc = this.#toc.filter(entry => entry.target_guid !== removed?.guid);
// the removed item is gone; settle on the item above it, or the
// new first item when the top one was removed
const above = this.#items[index - 1] || this.#items[0];
return { guid: removed?.guid || edit.guid, flashGuid: above?.guid || null, contentChanged: true };
}
if (action === 'move') {
const target = this.resolve_index(edit?.target_guid);
if (target === -1 || target === index) return null;
const [moved] = this.#items.splice(index, 1);
let at = target;
if (index < target) at -= 1;
if (position === 'below') at += 1;
this.#items.splice(at, 0, moved);
return { guid: moved.guid, flashGuid: moved.guid, contentChanged: false };
}
return null;
}
/**
* Applies a normalized edit_scratchpad_toc edit to the table of contents:
* add (new entry referencing an item), edit (retitle, redescribe, or
* repoint an existing entry), remove (delete an entry).
* @param {Object} edit - The normalized edit { action, guid, target_guid, title, entry_description }.
* @returns {Object|null} { guid, flashGuid } for the affected entry, or null when the edit cannot apply.
*/
apply_toc_edit(edit) {
const action = String(edit?.action || '');
if (action === 'add') {
const target = this.resolve_index(edit?.target_guid);
if (target === -1) return null;
const entry = {
guid: this.#new_guid(),
target_guid: this.#items[target].guid,
title: String(edit?.title || this.#items[target].value || '').slice(0, 200)
};
if (edit?.entry_description) entry.description = String(edit.entry_description).slice(0, 300);
this.#toc.push(entry);
return { guid: entry.guid, flashGuid: entry.target_guid };
}
const index = this.#toc.findIndex(entry => entry.guid === edit?.guid);
if (index === -1) return null;
const entry = this.#toc[index];
if (action === 'edit') {
if (edit?.target_guid && this.resolve_index(edit.target_guid) !== -1) {
entry.target_guid = edit.target_guid;
}
if (edit?.title) entry.title = String(edit.title).slice(0, 200);
if (edit?.entry_description !== undefined) {
if (edit.entry_description) entry.description = String(edit.entry_description).slice(0, 300);
else delete entry.description;
}
return { guid: entry.guid, flashGuid: entry.target_guid };
}
if (action === 'remove') {
this.#toc.splice(index, 1);
return { guid: entry.guid, flashGuid: entry.target_guid };
}
return null;
}
/**
* Sorts table of contents entries by the position of their target item in
* the items array; entries whose target no longer exists keep their
* relative order after the resolvable ones.
* @param {Array} toc - The toc entries.
* @param {Array} items - The scratchpad items.
* @returns {Array<Object>} The sorted entries.
*/
static sort_toc(toc, items) {
const position = new Map((Array.isArray(items) ? items : []).map((item, index) => [item.guid, index]));
return (Array.isArray(toc) ? toc : [])
.map((entry, order) => ({ entry, order, at: position.get(entry?.target_guid) }))
.sort((a, b) => {
const aAt = a.at === undefined ? Number.MAX_SAFE_INTEGER : a.at;
const bAt = b.at === undefined ? Number.MAX_SAFE_INTEGER : b.at;
if (aAt !== bAt) return aAt - bAt;
return a.order - b.order;
})
.map(sorted => sorted.entry);
}
/**
* Maps a content type alias to the canonical scratchpad type, or null for
* unknown types.
* @param {string} type - The raw type.
* @returns {string|null} The canonical type.
*/
static canonical_type(type) {
const key = String(type || '').toLowerCase();
if (ScratchpadModel.TYPES.includes(key)) return key;
switch (key) {
case 'diagram_mermaid':
case 'mermaid':
return 'diagram-mermaid';
case 'list_step':
case 'list':
return 'list-step';
case 'color_hex':
case 'color':
return 'color-hex';
case 'math':
return 'equation';
default:
return null;
}
}
/**
* Removes empty inline formatting tags left behind by editing.
* @param {string} html - The item's inline html.
* @returns {string} The cleaned html.
*/
clean_value(html) {
let cleaned = html || '';
let previous = null;
while (cleaned !== previous) {
previous = cleaned;
cleaned = cleaned.replace(/<(b|i|u|strong|em|code|sup|sub)[^>]*>\s*<\/\1>/gi, '');
}
return cleaned;
}
/**
* Normalizes a stored item for the model, assigning a guid when missing.
* @param {Object} item - The stored item.
* @returns {Object} The normalized item.
*/
#normalize_item(item) {
const type = ScratchpadModel.canonical_type(item?.type) || 'text';
const normalized = { guid: item?.guid || this.#new_guid(), type: type, value: typeof item?.value === 'string' ? item.value : '' };
if (item?.caption !== undefined && item?.caption !== null && String(item.caption) !== '') normalized.caption = String(item.caption);
if (type === 'header') {
normalized.index = (Number.isInteger(item?.index) && item.index >= 1 && item.index <= 6) ? item.index : null;
}
if (type === 'list-step') {
normalized.ordered = !!item?.ordered;
normalized.index = (normalized.ordered && Number.isInteger(item?.index)) ? item.index : null;
}
if (type === 'code' && typeof item?.language === 'string' && item.language) {
normalized.language = item.language;
}
if (type === 'table') {
normalized['has-header'] = !!item?.['has-header'];
normalized['table-rows'] = Array.isArray(item?.['table-rows'])
? item['table-rows'].map(row => Array.isArray(row) ? row.map(cell => String(cell ?? '')) : [])
: [];
}
if (type === 'link') {
const source = (Array.isArray(item?.source) && item.source[0]) || {};
if (!normalized.value && source.title) normalized.value = String(source.title);
normalized.source = source.url ? [{ title: String(source.title || source.url), url: String(source.url) }] : [];
}
return normalized;
}
/**
* Normalizes a stored table of contents entry, assigning a guid when
* missing and dropping entries without a target.
* @param {Object} entry - The stored entry.
* @returns {Object|null} The normalized entry, or null when invalid.
*/
#normalize_toc_entry(entry) {
if (!entry || typeof entry !== 'object') return null;
const target = typeof entry.target_guid === 'string' ? entry.target_guid : '';
if (!target) return null;
const normalized = {
guid: entry.guid || this.#new_guid(),
target_guid: target,
title: String(entry.title || '').slice(0, 200)
};
if (entry.description) normalized.description = String(entry.description).slice(0, 300);
return normalized;
}
/**
* Generates a fresh identifier for an item or toc entry.
* @returns {string} A new guid.
*/
#new_guid() {
return crypto.randomUUID();
}
/**
* Serializes the items back into the stored conversation content shape.
* @returns {Array<Object>} The item array.
*/
#serialize_items() {
return this.#items.map(item => {
const out = { type: item.type, value: this.clean_value(item.value), guid: item.guid };
if (item.caption) out.caption = item.caption;
if (item.type === 'header') out.index = item.index;
if (item.type === 'list-step') {
out.ordered = item.ordered;
out.index = item.index;
}
if (item.type === 'code' && item.language) out.language = item.language;
if (item.type === 'table') {
out['has-header'] = item['has-header'];
out['table-rows'] = item['table-rows'];
}
if (item.type === 'link' && item.source && item.source.length > 0) out.source = item.source;
return out;
});
}
}
export default ScratchpadModel;
scratchpad-model.js
×
Type: Web, text/plain
13.29 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/plain
13.29 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File