diff --git a/app/src/main/assets/doc-comments-mcp.js b/app/src/main/assets/doc-comments-mcp.js new file mode 100644 index 000000000..7b0b84838 --- /dev/null +++ b/app/src/main/assets/doc-comments-mcp.js @@ -0,0 +1,779 @@ +// YouCoded's document-comments MCP server — the Claude Code half of T9a/T9b +// (docs/active/specs/2026-09-26-doc-comments-build-design.md §5, §9). +// +// WHY dependency-free: this file is executed by a PLAIN node process Claude +// Code spawns — no node_modules beside it on either platform, Termux on +// Android. It re-implements (never imports) the SAME mkdir-lock-plus-atomic- +// rename primitive desktop's cas-write.ts uses for the JSON comment sidecar, +// so this process and the app's main process genuinely exclude each other +// over the same file (§9.1) — ported from real behaviour, not from +// chatsearch.js, which has no mutex anywhere in it (design review 2, F12). +// The atomic tmp-write+rename SHAPE for the pending-mutation queue's request +// files IS chatsearch.js's own outbox precedent (submitRequest): each request +// is uniquely named, so two writers never race the same file there and no +// lock is needed for that half. +'use strict'; + +var fs = require('fs'); +var fsp = fs.promises; +var path = require('path'); +var crypto = require('crypto'); + +var PROTOCOL_FALLBACK = '2025-06-18'; +// Cosmetic only (shown in /mcp) — the actual mcp__{server}__{tool} prefix +// Claude Code composes comes from the --mcp-config file's OWN mcpServers +// key (a fresh random value per deployment, deployClaudeCodeDocCommentsMcp), +// never from this string. See this file's TS header, finding #2. +var SERVER_ID = 'youcoded-doc-comments'; +var PROJECT_ROOT = process.env.YOUCODED_PROJECT_ROOT || ''; +// This session's own per-deployment secret (finding #1) — included on every +// pending-mutation request so the main-process queue can refuse anything it +// didn't hand out to THIS session's own script. +var REQUEST_TOKEN = process.env.YOUCODED_MCP_TOKEN || ''; + +// --------------------------------------------------------------------------- +// Path containment (design §1.5, review 2 F1's corrected algorithm: realpath +// the FULL joined path, walking up to the nearest existing ancestor for a +// not-yet-existing leaf, never falling through to the raw path on ENOENT). +// This is the SAME algorithm doc-comments-store.ts's TS implementation uses — +// ported here because this process cannot import that module. +// --------------------------------------------------------------------------- +var MAX_WALKUP_DEPTH = 200; + +function realpathWithNonexistentTail(targetAbs) { + return fsp.realpath(targetAbs).catch(function (e) { + if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') throw e; + var segments = []; + var dir = targetAbs; + function step(depth) { + if (depth >= MAX_WALKUP_DEPTH) return null; + var parent = path.dirname(dir); + if (parent === dir) return null; + segments.unshift(path.basename(dir)); + return fsp.realpath(parent).then(function (realParent) { + return path.join.apply(path, [realParent].concat(segments)); + }, function (e2) { + if (e2.code !== 'ENOENT' && e2.code !== 'ENOTDIR') throw e2; + dir = parent; + return step(depth + 1); + }); + } + return step(0); + }); +} + +function checkContainment(realProjectRoot, abs) { + return realpathWithNonexistentTail(abs).then(function (realAbs) { + if (realAbs === null || realAbs === undefined) return null; + var withSep = realProjectRoot.charAt(realProjectRoot.length - 1) === path.sep + ? realProjectRoot + : realProjectRoot + path.sep; + return realAbs === realProjectRoot || realAbs.indexOf(withSep) === 0 ? realAbs : null; + }); +} + +/** Windows strips a trailing '.'/' ' off a path component when it resolves + * one (design §3.2's own T8-review citation, F4) — mirrored here so this + * script's dispatch decision can never disagree with the main process's. */ +function stripWindowsTrailingDotsAndSpaces(filePath) { + return process.platform === 'win32' ? filePath.replace(/[. ]+$/, '') : filePath; +} + +function nativeFormatFor(filePath) { + var ext = path.extname(stripWindowsTrailingDotsAndSpaces(filePath)).toLowerCase(); + if (ext === '.docx') return 'docx'; + if (ext === '.xlsx') return 'xlsx'; + return null; +} + +var SIDECAR_DIR = ['.youcoded', 'comments']; +var PENDING_DIR = ['.youcoded', 'comments', '.pending']; + +/** Resolves filePath against the ONE trusted project root this process was + * spawned with (env, never model input — see this file's TS header) and + * returns every location a caller below needs: the JSON sidecar path, the + * realpathed root (for deriving the .pending/ queue path), and the source + * file's own containment-verified absolute path (for a docx/xlsx target). */ +function locate(filePath) { + if (!PROJECT_ROOT) { + return Promise.resolve({ error: 'no-project-root' }); + } + return fsp.realpath(PROJECT_ROOT).catch(function () { + return null; + }).then(function (realProjectRoot) { + if (!realProjectRoot) return { error: 'path-outside-project' }; + var abs = path.resolve(realProjectRoot, filePath); + return checkContainment(realProjectRoot, abs).then(function (realAbs) { + if (realAbs === null) return { error: 'path-outside-project' }; + var rel = path.relative(realProjectRoot, realAbs); + var sidecarPath = path.join.apply(path, [realProjectRoot].concat(SIDECAR_DIR, [rel + '.json'])); + var pendingDir = path.join.apply(path, [realProjectRoot].concat(PENDING_DIR)); + return { realProjectRoot: realProjectRoot, sidecarPath: sidecarPath, sourceAbsolutePath: realAbs, pendingDir: pendingDir }; + }); + }); +} + +// --------------------------------------------------------------------------- +// The JSON sidecar's own mkdir-lock + atomic-write mutex — a real, +// independent reimplementation of cas-write.ts's mutateFileUnderLock (design +// §9.1: "the mutual-exclusion half of T9a is novel work, not a port" — the +// SAME algorithm, same constants, so this process and the app's main process +// genuinely exclude each other over the identical lock path). +// --------------------------------------------------------------------------- +var LOCK_RETRY_MS = 10; +var LOCK_MAX_WAIT_MS = 3000; +var LOCK_STALE_MS = 30000; + +function sleep(ms) { + return new Promise(function (resolve) { setTimeout(resolve, ms); }); +} + +function acquireLock(lock) { + var start = Date.now(); + function attempt() { + return fsp.mkdir(lock).then(function () { + return true; + }, function (e) { + var contention = e.code === 'EEXIST' || e.code === 'EPERM' || e.code === 'EACCES' || e.code === 'EBUSY'; + if (!contention) throw e; + return fsp.stat(lock).then(function (stat) { + if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { + return fsp.rm(lock, { recursive: true, force: true }).then(attempt); + } + return afterWait(); + }, function () { + return afterWait(); + }); + }); + } + function afterWait() { + if (Date.now() - start > LOCK_MAX_WAIT_MS) return false; + return sleep(LOCK_RETRY_MS).then(attempt); + } + return attempt(); +} + +function atomicWrite(target, content) { + var tmp = target + '.' + process.pid + '.' + Date.now() + '.tmp'; + return fsp.writeFile(tmp, content, 'utf8').then(function () { + return fsp.open(tmp, 'r+'); + }).then(function (fh) { + return fh.sync().then(function () { + return fh.close(); + }); + }).then(function () { + return fsp.rename(tmp, target); + }).catch(function (e) { + return fsp.unlink(tmp).catch(function () {}).then(function () { + throw e; + }); + }); +} + +function mutateFileUnderLock(target, mutate) { + return fsp.mkdir(path.dirname(target), { recursive: true }).then(function () { + var lock = target + '.lock'; + return acquireLock(lock).then(function (acquired) { + if (!acquired) return false; + return fsp.readFile(target, 'utf8').catch(function (e) { + if (e.code !== 'ENOENT') throw e; + return null; + }).then(function (onDisk) { + var next = mutate(onDisk); + return Promise.resolve(next).then(function (resolved) { + if (resolved === null) return true; + return atomicWrite(target, resolved).then(function () { + return true; + }); + }); + }).finally(function () { + return fsp.rm(lock, { recursive: true, force: true }).catch(function () {}); + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// The JSON sidecar's own shape (§1.1/§1.3) — hand-copied, this script cannot +// import shared/doc-comments-types.ts. +// --------------------------------------------------------------------------- +function emptySidecar() { + return { version: 1, comments: [] }; +} + +function parseSidecar(onDisk) { + if (onDisk === null) return { ok: true, file: emptySidecar() }; + var parsed; + try { + parsed = JSON.parse(onDisk); + } catch (e) { + return { ok: false, error: 'sidecar-corrupt' }; + } + if (!parsed || typeof parsed !== 'object' || parsed.version !== 1 || !Array.isArray(parsed.comments)) { + return { ok: false, error: 'sidecar-corrupt' }; + } + return { ok: true, file: parsed }; +} + +function findComment(file, id) { + for (var i = 0; i < file.comments.length; i++) { + if (file.comments[i].id === id) return file.comments[i]; + } + return undefined; +} + +function replaceComment(file, id, next) { + return { + version: file.version, + comments: file.comments.map(function (c) { return c.id === id ? next : c; }), + }; +} + +function mutateSidecar(sidecarPath, apply) { + var outcome = null; + return mutateFileUnderLock(sidecarPath, function (onDisk) { + var parsed = parseSidecar(onDisk); + if (!parsed.ok) { + outcome = parsed; + return null; + } + var applied = apply(parsed.file); + if (applied === 'not-found') { + outcome = { ok: false, error: 'comment-not-found' }; + return null; + } + if (!applied.file) { + outcome = applied; + return null; + } + outcome = { ok: true }; + for (var k in applied.extra) if (Object.prototype.hasOwnProperty.call(applied.extra, k)) outcome[k] = applied.extra[k]; + return JSON.stringify(applied.file); + }).then(function (acquired) { + if (!acquired) return { ok: false, error: 'lock-timeout' }; + return outcome; + }); +} + +// --------------------------------------------------------------------------- +// The pending-mutation queue client (§9.2, T9b) — for a docx/xlsx target, +// this script "never touches the file directly" (§1.6): it writes a request +// under .youcoded/comments/.pending/.json (atomic tmp-then-rename, no +// lock needed — each request is uniquely named, chatsearch.js's own outbox +// precedent) and polls for the main process's .result.json. +// +// POLL_TIMEOUT_MS is a benchmarked, explicitly-set value (design review 2, +// F13 retracted an earlier, unsupported "~3s" citation): a real add/reply/ +// resolve/move round trip against docx-comments.ts (read+parse+mutate+write+ +// verify) measured 6-11ms against a 6MB fixture padded with random-content +// media entries (images do not get re-parsed — only the touched XML parts +// do), so 8000ms leaves generous headroom for a slower disk, antivirus +// scanning, or a larger real-world file this session could not fabricate a +// fixture for, while still failing an honest, specific way on a genuine hang +// instead of waiting minutes. +// Overridable via env for tests ONLY (a pinning test proves the queue's own +// bounded-timeout behaviour without a real test waiting out the production +// value) — production launches (session-manager.ts) never set this. +var POLL_TIMEOUT_MS = parseInt(process.env.YOUCODED_DOC_COMMENTS_MCP_POLL_TIMEOUT_MS || '', 10) || 8000; +var POLL_INTERVAL_MS = 50; +// Extra wait once the app has claimed a request (see giveUp below): it is +// actively applying it, so a little more patience beats a duplicate. +var CLAIMED_GRACE_MS = 30000; + +// WHY the highest existing number + 1, never the reply count + 1 +// (2026-09-28 PR review): after a middle reply was deleted (r1, r3 left), the +// count minted "-r3" again — two replies with one id, so editing the new one +// changed the old one and deleting it removed both. Mirrors +// doc-comments-store.ts's nextReplyId. +function nextReplyId(comment) { + var prefix = comment.id + '-r'; + var max = 0; + for (var i = 0; i < comment.replies.length; i++) { + var rid = String(comment.replies[i].id || ''); + if (rid.indexOf(prefix) !== 0) continue; + var n = parseInt(rid.slice(prefix.length), 10); + if (n > max) max = n; + } + return prefix + (Math.max(max, comment.replies.length) + 1); +} + +function submitPendingMutation(kind, located, fields) { + var id = crypto.randomUUID(); + var request = { + id: id, + kind: kind, + format: fields.format, + path: fields.path, + projectRoot: located.realProjectRoot, + // The applier NEVER trusts this field for authorization (design note on + // PendingMutationRequest.projectRoot, shared/doc-comments-types.ts) — the + // token below is the real boundary. Sent anyway for shape-compat/ + // diagnostics only. + token: REQUEST_TOKEN, + createdAt: Date.now(), + }; + if (fields.commentId !== undefined) request.commentId = fields.commentId; + if (fields.text !== undefined) request.text = fields.text; + if (fields.author !== undefined) request.author = fields.author; + if (fields.selector !== undefined) request.selector = fields.selector; + if (fields.newSelector !== undefined) request.newSelector = fields.newSelector; + + var requestPath = path.join(located.pendingDir, id + '.json'); + var resultPath = path.join(located.pendingDir, id + '.result.json'); + var tmp = requestPath + '.' + process.pid + '.tmp'; + + return fsp.mkdir(located.pendingDir, { recursive: true }).then(function () { + return fsp.writeFile(tmp, JSON.stringify(request), 'utf8'); + }).then(function () { + return fsp.rename(tmp, requestPath); + }).then(function () { + var deadline = Date.now() + POLL_TIMEOUT_MS; + function poll() { + return fsp.readFile(resultPath, 'utf8').then(function (raw) { + return fsp.unlink(resultPath).catch(function () {}).then(function () { + try { + return JSON.parse(raw); + } catch (e) { + return { ok: false, error: 'malformed-result' }; + } + }); + }, function (e) { + if (e.code !== 'ENOENT') throw e; + if (Date.now() >= deadline) return giveUp(); + return sleep(POLL_INTERVAL_MS).then(poll); + }); + } + // WHY withdraw on timeout (2026-09-28 PR review): the request used to be + // left in place, so a merely SLOW app applied it after the assistant had + // been told it failed — and the assistant's retry then posted the same + // comment or reply twice. The app now claims a request (renames it) + // before acting on it, so deleting it here settles, atomically, which + // side got it: deleted = never applied, safe to retry; already gone = + // the app is applying it, so keep waiting a while longer instead of + // inviting a duplicate. + function giveUp() { + return fsp.unlink(requestPath).then(function () { + // Adversarial review 2026-09-27, finding #4: state only what is + // actually known. The most common real cause is the app being + // closed, which "try again" alone cannot fix. + return { + ok: false, + error: 'timed-out — YouCoded did not pick this up within ' + + (POLL_TIMEOUT_MS / 1000) + 's, so nothing was changed. YouCoded may not be open.', + }; + }, function (e) { + if (e.code !== 'ENOENT') throw e; + return waitForClaimed(Date.now() + CLAIMED_GRACE_MS); + }); + } + function waitForClaimed(graceDeadline) { + return fsp.readFile(resultPath, 'utf8').then(function (raw) { + return fsp.unlink(resultPath).catch(function () {}).then(function () { + try { + return JSON.parse(raw); + } catch (e) { + return { ok: false, error: 'malformed-result' }; + } + }); + }, function (e) { + if (e.code !== 'ENOENT') throw e; + if (Date.now() >= graceDeadline) { + return { + ok: false, + error: 'still-running — YouCoded started this change but has not finished yet. It may still complete: check with ReadFileComments before trying again.', + }; + } + return sleep(POLL_INTERVAL_MS).then(function () { return waitForClaimed(graceDeadline); }); + }); + } + return poll(); + }); +} + +// --------------------------------------------------------------------------- +// The six operations. Every one resolves the target first (project +// containment, §1.5) and then either touches the JSON sidecar directly (a +// plain-text/markdown/code target) or submits a pending-mutation request (a +// docx/xlsx target, §9.2) — the SAME by-extension dispatch decision every +// other surface in this feature makes (doc-comments-dispatch.ts's own +// nativeFormatFor, ported above). +// --------------------------------------------------------------------------- +function readFileComments(args) { + return locate(args.path).then(function (located) { + if (located.error) return { ok: false, error: located.error }; + // Review finding #5: decided on located.sourceAbsolutePath (the + // REALPATH'd, containment-verified target locate() already computed + // above), never the caller's raw args.path -- a .txt-named symlink + // pointing at a real .docx is judged as the .docx it actually is. + var format = nativeFormatFor(located.sourceAbsolutePath); + if (format) { + return submitPendingMutation('list', located, { format: format, path: args.path }); + } + return fsp.readFile(located.sidecarPath, 'utf8').catch(function (e) { + if (e.code === 'ENOENT') return null; + throw e; + }).then(function (raw) { + var parsed = parseSidecar(raw); + if (!parsed.ok) return parsed; + return { ok: true, comments: parsed.file.comments }; + }); + }); +} + +function replyToComment(args) { + return locate(args.path).then(function (located) { + if (located.error) return { ok: false, error: located.error }; + // Review finding #5: decided on located.sourceAbsolutePath (the + // REALPATH'd, containment-verified target locate() already computed + // above), never the caller's raw args.path -- a .txt-named symlink + // pointing at a real .docx is judged as the .docx it actually is. + var format = nativeFormatFor(located.sourceAbsolutePath); + if (format) { + return submitPendingMutation('reply', located, { + format: format, path: args.path, commentId: args.commentId, text: args.text, author: 'assistant', + }); + } + return mutateSidecar(located.sidecarPath, function (file) { + var comment = findComment(file, args.commentId); + if (!comment) return 'not-found'; + var replyId = nextReplyId(comment); + var next = {}; + for (var k in comment) if (Object.prototype.hasOwnProperty.call(comment, k)) next[k] = comment[k]; + next.replies = comment.replies.concat([{ id: replyId, author: 'assistant', text: args.text, createdAt: Date.now() }]); + return { file: replaceComment(file, args.commentId, next), extra: {} }; + }); + }); +} + +function resolveComment(args) { + return locate(args.path).then(function (located) { + if (located.error) return { ok: false, error: located.error }; + // Review finding #5: decided on located.sourceAbsolutePath (the + // REALPATH'd, containment-verified target locate() already computed + // above), never the caller's raw args.path -- a .txt-named symlink + // pointing at a real .docx is judged as the .docx it actually is. + var format = nativeFormatFor(located.sourceAbsolutePath); + if (format) { + return submitPendingMutation('resolve', located, { format: format, path: args.path, commentId: args.commentId, author: 'assistant' }); + } + return mutateSidecar(located.sidecarPath, function (file) { + var comment = findComment(file, args.commentId); + if (!comment) return 'not-found'; + var next = {}; + for (var k in comment) if (Object.prototype.hasOwnProperty.call(comment, k)) next[k] = comment[k]; + next.resolved = true; + next.history = comment.history.concat([{ by: 'assistant', at: Date.now(), action: 'resolved' }]); + return { file: replaceComment(file, args.commentId, next), extra: {} }; + }); + }); +} + +function reopenComment(args) { + return locate(args.path).then(function (located) { + if (located.error) return { ok: false, error: located.error }; + // Review finding #5: decided on located.sourceAbsolutePath (the + // REALPATH'd, containment-verified target locate() already computed + // above), never the caller's raw args.path -- a .txt-named symlink + // pointing at a real .docx is judged as the .docx it actually is. + var format = nativeFormatFor(located.sourceAbsolutePath); + if (format) { + return submitPendingMutation('reopen', located, { format: format, path: args.path, commentId: args.commentId, author: 'assistant' }); + } + return mutateSidecar(located.sidecarPath, function (file) { + var comment = findComment(file, args.commentId); + if (!comment) return 'not-found'; + var next = {}; + for (var k in comment) if (Object.prototype.hasOwnProperty.call(comment, k)) next[k] = comment[k]; + next.resolved = false; + next.history = comment.history.concat([{ by: 'assistant', at: Date.now(), action: 'reopened' }]); + return { file: replaceComment(file, args.commentId, next), extra: {} }; + }); + }); +} + +function addComment(args) { + return locate(args.path).then(function (located) { + if (located.error) return { ok: false, error: located.error }; + // Review finding #5: decided on located.sourceAbsolutePath (the + // REALPATH'd, containment-verified target locate() already computed + // above), never the caller's raw args.path -- a .txt-named symlink + // pointing at a real .docx is judged as the .docx it actually is. + var format = nativeFormatFor(located.sourceAbsolutePath); + if (format) { + return submitPendingMutation('add', located, { + format: format, path: args.path, selector: args.selector, text: args.text, author: 'assistant', + }); + } + var id = 'c-' + crypto.randomUUID(); + var comment = { + id: id, path: args.path, selector: args.selector, text: args.text, author: 'assistant', + createdAt: Date.now(), replies: [], resolved: false, history: [], + }; + return mutateSidecar(located.sidecarPath, function (file) { + return { file: { version: file.version, comments: file.comments.concat([comment]) }, extra: { id: id } }; + }); + }); +} + +function moveComment(args) { + return locate(args.path).then(function (located) { + if (located.error) return { ok: false, error: located.error }; + // Review finding #5: decided on located.sourceAbsolutePath (the + // REALPATH'd, containment-verified target locate() already computed + // above), never the caller's raw args.path -- a .txt-named symlink + // pointing at a real .docx is judged as the .docx it actually is. + var format = nativeFormatFor(located.sourceAbsolutePath); + if (format) { + return submitPendingMutation('move', located, { format: format, path: args.path, commentId: args.commentId, newSelector: args.newSelector }); + } + return mutateSidecar(located.sidecarPath, function (file) { + var comment = findComment(file, args.commentId); + if (!comment) return 'not-found'; + var next = {}; + for (var k in comment) if (Object.prototype.hasOwnProperty.call(comment, k)) next[k] = comment[k]; + next.selector = args.newSelector; + return { file: replaceComment(file, args.commentId, next), extra: {} }; + }); + }); +} + +// --------------------------------------------------------------------------- +// Tool definitions — descriptions copied VERBATIM from +// shared/doc-comments-tool-text.ts (this process cannot import it; a pinning +// test asserts the two copies agree). AddComment's wording is R4's own +// signed constraint (§5.1) — never paraphrased. +// --------------------------------------------------------------------------- +var TEXT_QUOTE_SELECTOR_SCHEMA = { + type: 'object', + properties: { + type: { type: 'string', enum: ['TextQuoteSelector'] }, + exact: { type: 'string', description: 'The exact text being commented on.' }, + prefix: { type: 'string', description: '~32 chars of context immediately before the quote (whitespace-collapsed).' }, + suffix: { type: 'string', description: '~32 chars of context immediately after the quote (whitespace-collapsed).' }, + occurrence: { type: 'integer', minimum: 0, description: '0-indexed: which match of "exact" in the document this is.' }, + }, + required: ['type', 'exact', 'prefix', 'suffix', 'occurrence'], + additionalProperties: false, +}; + +var CELL_SELECTOR_SCHEMA = { + type: 'object', + properties: { + type: { type: 'string', enum: ['CellSelector'] }, + cell: { type: 'string', description: 'Spreadsheet cell reference, e.g. "C4".' }, + sheet: { type: 'string', description: 'Sheet tab name; omit if the workbook has only one sheet.' }, + }, + required: ['type', 'cell'], + additionalProperties: false, +}; + +var COMMENT_SELECTOR_SCHEMA = { + description: 'Where the comment attaches: a text quote (with surrounding context) for prose files, or a cell for spreadsheets.', + oneOf: [ + { type: 'object', properties: { kind: { const: 'text' }, selector: TEXT_QUOTE_SELECTOR_SCHEMA }, required: ['kind', 'selector'], additionalProperties: false }, + { type: 'object', properties: { kind: { const: 'cell' }, selector: CELL_SELECTOR_SCHEMA }, required: ['kind', 'selector'], additionalProperties: false }, + ], +}; + +var PATH_PROP = { type: 'string', description: 'Absolute or workspace-relative path of the commented file.' }; +var COMMENT_ID_PROP = { type: 'string', description: 'The comment id — from a prior ReadFileComments call, or a reference the user handed you.' }; + +var TOOLS = [ + { + name: 'ReadFileComments', + description: 'Every comment on a file, with status (anchored/detached), replies, and resolve history. Read this before replying to, resolving, reopening, or moving any comment — the ids and current state it returns are what those tools need.', + inputSchema: { type: 'object', properties: { path: PATH_PROP }, required: ['path'], additionalProperties: false }, + }, + { + name: 'ReplyToComment', + description: 'Reply to a comment the user (or a previous turn) left on this file — for a plain-text/markdown/code comment thread, or a real Word/Excel comment. Use it to answer a question they left, or to say what you did about something they flagged.', + inputSchema: { + type: 'object', + properties: { path: PATH_PROP, commentId: COMMENT_ID_PROP, text: { type: 'string', description: 'The reply text.' } }, + required: ['path', 'commentId', 'text'], + additionalProperties: false, + }, + }, + { + name: 'ResolveComment', + description: 'Mark a comment resolved — recorded in its history as resolved by the assistant. Use this once you\'ve addressed what a comment asked for; R6 (nothing silently lost) is why the resolve/reopen history stays visible.', + inputSchema: { type: 'object', properties: { path: PATH_PROP, commentId: COMMENT_ID_PROP }, required: ['path', 'commentId'], additionalProperties: false }, + }, + { + name: 'ReopenComment', + description: 'Reopen a comment that was marked resolved — clears its resolved state so it shows as open again. Use this if a resolved comment\'s issue turns out not to be fully addressed.', + inputSchema: { type: 'object', properties: { path: PATH_PROP, commentId: COMMENT_ID_PROP }, required: ['path', 'commentId'], additionalProperties: false }, + }, + { + name: 'AddComment', + description: 'Leave a comment on this file — sparingly. Use this only for something that clearly needs the user\'s attention or a decision from them, never to narrate what you just did or are about to do. If you\'re explaining your own edit, say so in your reply to them instead; if nothing needs their decision, don\'t add a comment at all.', + inputSchema: { + type: 'object', + properties: { path: PATH_PROP, selector: COMMENT_SELECTOR_SCHEMA, text: { type: 'string', description: 'The comment text.' } }, + required: ['path', 'selector', 'text'], + additionalProperties: false, + }, + }, + { + name: 'MoveComment', + description: 'Repoint a comment to a new location in the file, after the text or cell it was attached to moved or changed. This is the re-anchor half of R6 (nothing silently lost): after you fix what a comment asked for, use this — together with ReplyToComment/ResolveComment as appropriate — so the comment keeps pointing at something real instead of going quietly detached.', + inputSchema: { + type: 'object', + properties: { path: PATH_PROP, commentId: COMMENT_ID_PROP, newSelector: COMMENT_SELECTOR_SCHEMA }, + required: ['path', 'commentId', 'newSelector'], + additionalProperties: false, + }, + }, +]; + +// --------------------------------------------------------------------------- +// JSON-RPC framing — same shape as claude-code-mcp.ts's LINK_SERVER_JS. +// --------------------------------------------------------------------------- +function write(msg) { + process.stdout.write(JSON.stringify(msg) + '\n'); +} +function replyResult(id, res) { + write({ jsonrpc: '2.0', id: id, result: res }); +} +function replyError(id, code, message) { + write({ jsonrpc: '2.0', id: id, error: { code: code, message: message } }); +} +function messageOf(err) { + if (err && typeof err === 'object' && typeof err.message === 'string') return err.message; + return 'unknown error'; +} + +function textResult(text, isError) { + return { content: [{ type: 'text', text: text }], isError: !!isError }; +} + +function formatOpError(name, result) { + var detail = typeof result.error === 'string' ? result.error : 'unknown error'; + return textResult(name + ' failed: ' + detail, true); +} + +function requireString(args, field) { + return typeof args === 'object' && args !== null && typeof args[field] === 'string' && args[field].length > 0; +} + +function callTool(id, params) { + var name = params && params.name; + var args = (params && params.arguments) || {}; + var tool = null; + for (var i = 0; i < TOOLS.length; i++) if (TOOLS[i].name === name) tool = TOOLS[i]; + if (!tool) { + replyError(id, -32602, 'Unknown tool: ' + String(name)); + return; + } + + var handler = null; + if (name === 'ReadFileComments') { + handler = requireString(args, 'path') + ? readFileComments(args).then(function (r) { + if (!r.ok) return formatOpError('ReadFileComments', r); + if (!r.comments || r.comments.length === 0) return textResult('No comments on ' + args.path + '.'); + return textResult(r.comments.length + ' comment(s) on ' + args.path + ' (JSON):\n' + JSON.stringify(r.comments, null, 2)); + }) + : Promise.resolve(textResult('ReadFileComments failed: path is required.', true)); + } else if (name === 'ReplyToComment') { + handler = (requireString(args, 'path') && requireString(args, 'commentId') && requireString(args, 'text')) + ? replyToComment(args).then(function (r) { + if (!r.ok) return formatOpError('ReplyToComment', r); + // A docx/xlsx reply's queue result MAY carry the persisted + // CommentReply once docx-comments.ts's/xlsx-comments.ts's own reply + // function returns one (its ordinal id can't be pre-computed) — + // included here when present, never assumed. + var replyId = r.reply && r.reply.id ? r.reply.id : null; + return textResult((replyId ? 'Reply ' + replyId + ' added' : 'Reply added') + ' to comment ' + args.commentId + ' on ' + args.path + '.'); + }) + : Promise.resolve(textResult('ReplyToComment failed: path, commentId and text are required.', true)); + } else if (name === 'ResolveComment') { + handler = (requireString(args, 'path') && requireString(args, 'commentId')) + ? resolveComment(args).then(function (r) { + return r.ok ? textResult('Comment ' + args.commentId + ' on ' + args.path + ' marked resolved.') : formatOpError('ResolveComment', r); + }) + : Promise.resolve(textResult('ResolveComment failed: path and commentId are required.', true)); + } else if (name === 'ReopenComment') { + handler = (requireString(args, 'path') && requireString(args, 'commentId')) + ? reopenComment(args).then(function (r) { + return r.ok ? textResult('Comment ' + args.commentId + ' on ' + args.path + ' reopened.') : formatOpError('ReopenComment', r); + }) + : Promise.resolve(textResult('ReopenComment failed: path and commentId are required.', true)); + } else if (name === 'AddComment') { + handler = (requireString(args, 'path') && args.selector && typeof args.selector === 'object' && requireString(args, 'text')) + ? addComment(args).then(function (r) { + return r.ok ? textResult('Comment added to ' + args.path + ' (id: ' + r.id + ').') : formatOpError('AddComment', r); + }) + : Promise.resolve(textResult('AddComment failed: path, selector and text are required.', true)); + } else if (name === 'MoveComment') { + handler = (requireString(args, 'path') && requireString(args, 'commentId') && args.newSelector && typeof args.newSelector === 'object') + ? moveComment(args).then(function (r) { + if (!r.ok) return formatOpError('MoveComment', r); + // Code review 2026-09-27, desktop F1: an xlsx move's queue result + // carries the FRESH id (its old id's embedded-cell hint goes stale + // the instant the comment moves — pending-mutation-queue.ts's own + // WHY on its 'move' branch); stating it here is what lets a + // follow-up ReplyToComment/ResolveComment call use the new id + // instead of paying for the full-workbook fallback scan. A docx + // move (or a plain-text sidecar move, neither of which changes its + // id) never sets r.id, so this falls back to the unchanged text + // exactly as before — same shape as ReplyToComment's own replyId + // handling just above. + var newId = r.id || null; + return textResult('Comment ' + args.commentId + ' on ' + args.path + ' repointed.' + (newId ? ' New id: ' + newId + '.' : '')); + }) + : Promise.resolve(textResult('MoveComment failed: path, commentId and newSelector are required.', true)); + } else { + // Defensive: unreachable while TOOLS and this if-chain agree on names — + // never leaves a request unanswered if they ever drift. + handler = Promise.resolve(textResult('Internal error: no handler wired for ' + name + '.', true)); + } + + handler.then(function (result) { + replyResult(id, result); + }, function (err) { + replyResult(id, textResult(name + ' failed: ' + messageOf(err), true)); + }); +} + +function handle(msg) { + var id = msg.id; + var method = msg.method; + if (id === undefined || id === null) return; + if (method === 'initialize') { + var asked = msg.params && typeof msg.params.protocolVersion === 'string' ? msg.params.protocolVersion : PROTOCOL_FALLBACK; + replyResult(id, { + protocolVersion: asked, + capabilities: { tools: {} }, + serverInfo: { name: SERVER_ID, version: '1.0.0' }, + }); + return; + } + if (method === 'ping') { replyResult(id, {}); return; } + if (method === 'tools/list') { replyResult(id, { tools: TOOLS }); return; } + if (method === 'tools/call') { callTool(id, msg.params); return; } + replyError(id, -32601, 'Unknown method: ' + String(method)); +} + +var buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', function (chunk) { + buffer += chunk; + var idx = buffer.indexOf('\n'); + while (idx >= 0) { + var line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (line.length) { + var msg = null; + try { + msg = JSON.parse(line); + } catch (err) { + process.stderr.write('doc-comments-mcp: ignoring unparseable line: ' + messageOf(err) + '\n'); + } + if (msg && typeof msg === 'object') handle(msg); + } + idx = buffer.indexOf('\n'); + } +}); +process.stdin.on('end', function () { process.exit(0); }); +process.stdout.on('error', function () { process.exit(0); }); diff --git a/app/src/main/kotlin/com/youcoded/app/artifacts/CasWrite.kt b/app/src/main/kotlin/com/youcoded/app/artifacts/CasWrite.kt index 64e7d3182..054bad34d 100644 --- a/app/src/main/kotlin/com/youcoded/app/artifacts/CasWrite.kt +++ b/app/src/main/kotlin/com/youcoded/app/artifacts/CasWrite.kt @@ -29,6 +29,96 @@ data class CasResult( val actualUpdatedAt: String?, ) +/** + * Acquire the mkdir lock at [lockPath], blocking (real `Thread.sleep` retries — + * this whole module is the synchronous, non-suspend style `casWrite` + * established) until acquired or [LOCK_MAX_WAIT_MS] elapses. Shared by + * `casWrite` and `mutateFileUnderLock` below so both use IDENTICAL lock + * semantics — same mkdir-is-atomic primitive, same 30s stale-lock break — + * which matters once a SECOND caller (`mutateFileUnderLock`, added for F1, + * T4 doc-comments implementation review) needs to exclude not just other + * callers of `casWrite` but a wholly separate process racing the same file. + */ +private fun acquireLock(lockPath: Path): Boolean { + val start = System.currentTimeMillis() + while (true) { + try { + Files.createDirectory(lockPath) + return true // Lock acquired + } catch (e: java.nio.file.FileAlreadyExistsException) { + // Stale-lock heuristic: if the lock dir is older than LOCK_STALE_MS, + // the holding process likely crashed — break the lock and retry. + try { + val attrs = Files.readAttributes(lockPath, BasicFileAttributes::class.java) + val mtime = attrs.lastModifiedTime().toMillis() + if (System.currentTimeMillis() - mtime > LOCK_STALE_MS) { + lockPath.toFile().deleteRecursively() + continue + } + } catch (_: Exception) { + // Ignore stat errors — lock may have just been released + } + if (System.currentTimeMillis() - start > LOCK_MAX_WAIT_MS) { + return false + } + Thread.sleep(LOCK_RETRY_MS) + } + } +} + +/** + * Read-modify-write [target] entirely INSIDE the mkdir lock — Kotlin port of + * desktop's `cas-write.ts` `mutateFileUnderLock` (F1, T4 doc-comments + * implementation review, blocker). This is the primitive for a file with no + * CAS version field of its own: a caller that reads outside the lock and + * writes back after loses an update from whoever wrote in between. Reuses + * [acquireLock] — the SAME lock path naming (`.lock`) and 30s + * stale-lock timeout `casWrite` above already uses — so this app's own + * writers, desktop's `cas-write.ts` (a dev instance and the built app sharing + * `~/.claude`), and the Claude Code MCP script's own dependency-free + * reimplementation of this exact algorithm (design §9.1 point 2, §9.2) all + * exclude each other over the same file, not just callers inside one JVM. + * + * @param mutate receives the current on-disk content (null when the file + * doesn't exist) and returns the new content, or null to skip + * the write entirely — a failed lookup must never write + * anything, not even an unchanged copy. + * @return false when the lock couldn't be acquired within the timeout. + */ +fun mutateFileUnderLock(target: Path, mutate: (String?) -> String?): Boolean { + Files.createDirectories(target.parent) + val lockPath = target.parent.resolve(target.fileName.toString() + ".lock") + if (!acquireLock(lockPath)) return false + try { + val onDisk: String? = try { + target.toFile().readText(Charsets.UTF_8) + } catch (e: java.io.FileNotFoundException) { + null + } catch (e: java.io.IOException) { + if (!target.toFile().exists()) null else throw e + } + val next = mutate(onDisk) + if (next != null) { + // Same atomic write shape as casWrite below: tmp → fsync → rename. + val tmp = target.parent.resolve(target.fileName.toString() + ".tmp") + tmp.toFile().writeText(next, Charsets.UTF_8) + FileChannel.open(tmp, StandardOpenOption.READ, StandardOpenOption.WRITE).use { ch -> ch.force(true) } + try { + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: java.nio.file.AtomicMoveNotSupportedException) { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING) + } + } + return true + } finally { + lockPath.toFile().deleteRecursively() + } +} + +/** Convenience overload accepting a String path (matches most call sites). */ +fun mutateFileUnderLock(target: String, mutate: (String?) -> String?): Boolean = + mutateFileUnderLock(File(target).toPath(), mutate) + /** * Atomic write-then-rename with optional CAS check, protected by a * mkdir-based lock. @@ -62,30 +152,8 @@ fun casWrite( val lockPath = target.parent.resolve(target.fileName.toString() + ".lock") - // Acquire lock — retry up to LOCK_MAX_WAIT_MS - val start = System.currentTimeMillis() - while (true) { - try { - Files.createDirectory(lockPath) - break // Lock acquired - } catch (e: java.nio.file.FileAlreadyExistsException) { - // Stale-lock heuristic: if the lock dir is older than LOCK_STALE_MS, - // the holding process likely crashed — break the lock and retry. - try { - val attrs = Files.readAttributes(lockPath, BasicFileAttributes::class.java) - val mtime = attrs.lastModifiedTime().toMillis() - if (System.currentTimeMillis() - mtime > LOCK_STALE_MS) { - lockPath.toFile().deleteRecursively() - continue - } - } catch (_: Exception) { - // Ignore stat errors — lock may have just been released - } - if (System.currentTimeMillis() - start > LOCK_MAX_WAIT_MS) { - return CasResult(committed = false, actualUpdatedAt = null) - } - Thread.sleep(LOCK_RETRY_MS) - } + if (!acquireLock(lockPath)) { + return CasResult(committed = false, actualUpdatedAt = null) } try { diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentTypes.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentTypes.kt new file mode 100644 index 000000000..263403ebd --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentTypes.kt @@ -0,0 +1,325 @@ +// Document comments — shared types. Android half of T1 +// (docs/active/specs/2026-09-26-doc-comments-build-design.md §1.1, +// "Web Annotation-flavored" record shape), ported for T16 (§3.2a). +// +// WHY this is a byte-for-byte port of desktop/src/shared/doc-comments-types.ts +// rather than a Kotlin-idiomatic redesign: three runtimes (Electron main, the +// React renderer, and now this Kotlin one) must agree on the SAME +// `PersistedComment` shape — the design's own §1.1 header comment on the TS +// file says this explicitly for the first two; T16 adds this one as the +// third. `toJson()` below produces the exact field names/nesting the TS +// `PersistedComment` serializes to (confirmed against +// `desktop/tests/fixtures/doc-comments/golden/*.json`, generated straight +// from the TS reader — see `generate-docx-golden.mjs`'s own header for why), +// so a future IPC wire-up (T4, out of this task's scope) can reuse this +// mapping unchanged rather than inventing a second one. +// +// F2 (T4 implementation review, major): `fromJson`/`toJson` used to rebuild +// every record field by field, silently dropping any key this Kotlin build +// doesn't know about (a newer schema field, or one written by another of the +// three-way JSON sidecar's implementations, design §9.1) — desktop avoids +// this by spreading (`{ ...comment, replies: [...] }`), which keeps every +// OTHER key untouched. `overlayJson` below is the same idea for org.json, +// which has no object-spread operator: clone the record's ORIGINAL +// `JSONObject` (kept as `raw`), then overlay only the fields this code +// actually knows about. Applied at every level the design calls out: the +// sidecar file itself, each `PersistedComment`, each `CommentReply`, and +// each `ResolveEvent` (a `history` entry) — NOT the selector, which stays a +// fully-typed, from-scratch shape (§2's anchoring pass owns its contents, +// unlike the passive pass-through fields around it). +package com.youcoded.app.doccomments + +import org.json.JSONArray +import org.json.JSONObject + +/** `'user' | 'assistant' | 'person:'` — a plain String rather than a + * Kotlin sealed class, since nothing in this task needs to pattern-match on + * it beyond formatting for JSON (T16 is read-only; the write path (T17) is + * the one that constructs a 'user'/'assistant' value from scratch). */ +typealias CommentAuthor = String + +/** + * Clone `base` (or start empty when null — a brand-new record has nothing to + * preserve) and apply `block`'s overlay on top. F2 fix: mirrors desktop's own + * `{ ...record, field: newValue }` spread, so a field this Kotlin code + * doesn't know about survives a round trip through this reader/writer + * untouched instead of the old field-by-field rebuild silently dropping it. + * Goes through a string round trip rather than iterating `keys()` into a + * fresh `JSONObject` — org.json's `JSONObject` has no public copy + * constructor for "every key", and a string round trip is the simplest way + * to get an independent clone that never aliases `base` (a caller may still + * hold a reference to it elsewhere, e.g. `CommentsSidecarFile.raw`). + */ +private fun overlayJson(base: JSONObject?, block: JSONObject.() -> Unit): JSONObject { + val out = if (base != null) JSONObject(base.toString()) else JSONObject() + out.block() + return out +} + +data class TextQuoteSelector( + val exact: String, + /** ~32 chars before, whitespace-collapsed. */ + val prefix: String, + /** ~32 chars after, whitespace-collapsed. */ + val suffix: String, + /** 0-indexed: which match of `exact` this was, at creation time. */ + val occurrence: Int, +) { + fun toJson(): JSONObject = JSONObject() + .put("type", "TextQuoteSelector") + .put("exact", exact) + .put("prefix", prefix) + .put("suffix", suffix) + .put("occurrence", occurrence) + + companion object { + // T4 (docComments:* Android IPC parity, §1.6) — the read side of the + // JSON sidecar (T1's own store round-trips these fields verbatim, so + // the Kotlin store needs the SAME parse the TS side gets for free via + // JSON.parse; org.json has no automatic mapping, hence fromJson pairs + // with every toJson above). + fun fromJson(o: JSONObject): TextQuoteSelector = TextQuoteSelector( + exact = o.optString("exact", ""), + prefix = o.optString("prefix", ""), + suffix = o.optString("suffix", ""), + occurrence = o.optInt("occurrence", 0), + ) + } +} + +data class CellSelector(val cell: String, val sheet: String? = null) { + fun toJson(): JSONObject { + val o = JSONObject().put("type", "CellSelector").put("cell", cell) + if (sheet != null) o.put("sheet", sheet) + return o + } + + companion object { + fun fromJson(o: JSONObject): CellSelector = CellSelector( + cell = o.optString("cell", ""), + sheet = if (o.has("sheet") && !o.isNull("sheet")) o.optString("sheet") else null, + ) + } +} + +sealed class CommentSelector { + data class Text(val selector: TextQuoteSelector, val lineHint: Pair? = null) : CommentSelector() + data class Cell(val selector: CellSelector) : CommentSelector() + + fun toJson(): JSONObject = when (this) { + is Text -> { + val o = JSONObject().put("kind", "text").put("selector", selector.toJson()) + if (lineHint != null) o.put("lineHint", JSONArray().put(lineHint.first).put(lineHint.second)) + o + } + is Cell -> JSONObject().put("kind", "cell").put("selector", selector.toJson()) + } + + companion object { + /** Returns null for a shape this union doesn't recognize — every + * caller (T4's IPC dispatch) treats a null selector as a + * missing/invalid field, never a crash on model- or renderer- + * supplied JSON. */ + fun fromJson(o: JSONObject?): CommentSelector? { + if (o == null) return null + val sel = o.optJSONObject("selector") ?: return null + return when (o.optString("kind", "")) { + "text" -> { + val hintArr = o.optJSONArray("lineHint") + val hint = if (hintArr != null && hintArr.length() == 2) Pair(hintArr.optInt(0), hintArr.optInt(1)) else null + Text(TextQuoteSelector.fromJson(sel), hint) + } + "cell" -> Cell(CellSelector.fromJson(sel)) + else -> null + } + } + } +} + +data class CommentReply( + val id: String, + val author: CommentAuthor, + val text: String, + val createdAt: Long, + // F2 (T4 implementation review, major): the reply's own original + // JSONObject, if it was read from disk — preserved so a reply carrying a + // field this Kotlin build predates (or one added by another of the + // three-way JSON sidecar's implementations, design §9.1) survives a + // round trip through THIS code untouched, mirroring desktop's own + // `{ ...reply }` spread. Excluded from `equals`/`hashCode` below — + // org.json's `JSONObject` has no structural `equals` of its own (it's + // reference equality), so two replies parsed from identical JSON text + // would otherwise compare UNEQUAL just because their `raw` objects are + // different instances. + val raw: JSONObject? = null, +) { + fun toJson(): JSONObject = overlayJson(raw) { + put("id", id) + put("author", author) + put("text", text) + put("createdAt", createdAt) + } + + override fun equals(other: Any?): Boolean = other is CommentReply && + id == other.id && author == other.author && text == other.text && createdAt == other.createdAt + override fun hashCode(): Int = java.util.Objects.hash(id, author, text, createdAt) + + companion object { + fun fromJson(o: JSONObject): CommentReply = CommentReply( + id = o.optString("id", ""), + author = o.optString("author", ""), + text = o.optString("text", ""), + createdAt = o.optLong("createdAt", 0L), + raw = o, + ) + } +} + +/** `'resolved' | 'reopened'`. */ +data class ResolveEvent( + val by: CommentAuthor, + val at: Long, + val action: String, + // F2 — see CommentReply's own doc comment for why this exists and is + // excluded from equals/hashCode. + val raw: JSONObject? = null, +) { + fun toJson(): JSONObject = overlayJson(raw) { + put("by", by) + put("at", at) + put("action", action) + } + + override fun equals(other: Any?): Boolean = other is ResolveEvent && by == other.by && at == other.at && action == other.action + override fun hashCode(): Int = java.util.Objects.hash(by, at, action) + + companion object { + fun fromJson(o: JSONObject): ResolveEvent = ResolveEvent( + by = o.optString("by", ""), + at = o.optLong("at", 0L), + action = o.optString("action", ""), + raw = o, + ) + } +} + +data class PersistedComment( + val id: String, + /** Project-relative (or absolute, for the fallback store — §1.4). */ + val path: String, + val selector: CommentSelector, + val text: String, + val author: CommentAuthor, + val createdAt: Long, + val replies: List, + val resolved: Boolean, + /** Full resolve/reopen audit trail — always empty for a freshly-read + * native comment (§3.2: "Word's own OOXML has no separate resolve/reopen + * AUDIT TRAIL... history starts empty for a freshly-read native + * comment"). */ + val history: List = emptyList(), + /** Set by the anchoring pass at READ time, never persisted. T16 never + * sets or reads this — anchoring runs downstream in the WebView + * (§3.2a: "needs no Kotlin port at all"). */ + val status: String? = null, + // F2 — see CommentReply's own doc comment for why this exists and is + // excluded from equals/hashCode. A brand-new comment (addComment) has + // none; a comment read off disk carries its own original object so an + // unknown top-level field (e.g. one a newer schema adds) survives every + // reply/resolve/reopen/move this Kotlin code performs on it. + val raw: JSONObject? = null, +) { + fun toJson(): JSONObject = overlayJson(raw) { + put("id", id) + put("path", path) + put("selector", selector.toJson()) + put("text", text) + put("author", author) + put("createdAt", createdAt) + put("replies", JSONArray(replies.map { it.toJson() })) + put("resolved", resolved) + put("history", JSONArray(history.map { it.toJson() })) + if (status != null) put("status", status) else remove("status") + } + + override fun equals(other: Any?): Boolean = other is PersistedComment && + id == other.id && path == other.path && selector == other.selector && text == other.text && + author == other.author && createdAt == other.createdAt && replies == other.replies && + resolved == other.resolved && history == other.history && status == other.status + override fun hashCode(): Int = + java.util.Objects.hash(id, path, selector, text, author, createdAt, replies, resolved, history, status) + + companion object { + /** Returns null for a record this reader can't make sense of (a + * missing/wrong-typed required field) — T4's sidecar reader treats + * that the same way desktop's `parseSidecar` treats a whole corrupt + * file: an honest refusal, never a thrown exception into the bridge + * or a silently-dropped comment. */ + fun fromJson(o: JSONObject): PersistedComment? { + val id = o.optString("id", "") + val path = o.optString("path", "") + val selector = CommentSelector.fromJson(o.optJSONObject("selector")) ?: return null + if (id.isEmpty() || path.isEmpty()) return null + val repliesArr = o.optJSONArray("replies") ?: JSONArray() + val replies = (0 until repliesArr.length()).map { CommentReply.fromJson(repliesArr.getJSONObject(it)) } + val historyArr = o.optJSONArray("history") ?: JSONArray() + val history = (0 until historyArr.length()).map { ResolveEvent.fromJson(historyArr.getJSONObject(it)) } + return PersistedComment( + id = id, + path = path, + selector = selector, + text = o.optString("text", ""), + author = o.optString("author", ""), + createdAt = o.optLong("createdAt", 0L), + replies = replies, + resolved = o.optBoolean("resolved", false), + history = history, + status = if (o.has("status") && !o.isNull("status")) o.optString("status") else null, + raw = o, + ) + } + } +} + +/** §1.3's sidecar file shape, `{ version: 1, comments: PersistedComment[] }` — + * Android's half of T1/T4's on-disk contract. `version` exists from day one + * (matching desktop's own comment on `CommentsSidecarFile`) so a future + * schema change can migrate on read instead of needing a flag day. */ +data class CommentsSidecarFile( + val version: Int = 1, + val comments: List, + // F2 — the sidecar's own original JSONObject, if read from disk, so a + // TOP-LEVEL unknown field (beside `version`/`comments`) survives every + // mutation. See CommentReply's own doc comment for why excluded below. + val raw: JSONObject? = null, +) { + fun toJson(): JSONObject = overlayJson(raw) { + put("version", version) + put("comments", JSONArray(comments.map { it.toJson() })) + } + + override fun equals(other: Any?): Boolean = other is CommentsSidecarFile && version == other.version && comments == other.comments + override fun hashCode(): Int = java.util.Objects.hash(version, comments) + + companion object { + fun empty(): CommentsSidecarFile = CommentsSidecarFile(1, emptyList()) + + /** Mirrors desktop's `parseSidecar` (doc-comments-store.ts): a + * missing file is `null` in, handled by the caller as "empty + * sidecar" — never confused with a PRESENT-but-corrupt one, which + * this returns null for instead (a hand-edited or half-written + * file is refused, not silently treated as empty, so a mutation + * never discards history it couldn't parse). */ + fun parse(onDisk: String): CommentsSidecarFile? { + val obj = try { JSONObject(onDisk) } catch (_: Exception) { return null } + if (obj.optInt("version", -1) != 1) return null + val arr = obj.optJSONArray("comments") ?: return null + val comments = mutableListOf() + for (i in 0 until arr.length()) { + val c = arr.optJSONObject(i) ?: return null + comments.add(PersistedComment.fromJson(c) ?: return null) + } + return CommentsSidecarFile(1, comments, raw = obj) + } + } +} diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsAnchor.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsAnchor.kt new file mode 100644 index 000000000..7b1136ec4 --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsAnchor.kt @@ -0,0 +1,148 @@ +// Document comments — re-anchoring after edits, Kotlin port of +// desktop/src/shared/doc-comments-anchor.ts's `resolveSelector` for T17 of the +// doc-comments build (docs/active/specs/2026-09-26-doc-comments-build- +// design.md §2.2, §3.3, §8 T17). +// +// WHY this exists even though §3.2a says the ANCHORING PASS "needs no Kotlin +// port at all... it runs downstream, in the WebView, over the same shared +// doc-comments-anchor.ts": that sentence is about computing a comment's +// `status` (`anchored`/`detached`) against the WebView's own rendered DOM +// text at LIST time (§2.3, T14's scope) — a genuinely different use of the +// same algorithm from what T17's WRITE path needs. Add/Move must find WHERE +// in `word/document.xml`'s OWN text (not mammoth's rendered HTML — this +// module has no access to it and doesn't need it, same reasoning as T10/T16's +// read path) a selector currently matches, so a fresh `w:commentRangeStart`/ +// `End` pair can be inserted there — headlessly, with no WebView involved at +// all (§3.2a's own F1 reasoning: the MCP script and a backgrounded PTY +// session must be able to mutate a comment with no WebView attached). Ported +// field-for-field from doc-comments-anchor.ts so both the WebView's status +// pass and this write path score candidates identically, per §2.2's own +// "this single function exists to prevent [disagreement]" rule. +package com.youcoded.app.doccomments + +/** A resolved text-quote anchor: character offsets into the `fullText` that + * was searched. `end` is exclusive, matching `String.substring`. */ +data class ResolvedRange(val start: Int, val end: Int) + +private val WHITESPACE = Regex("\\s+") + +/** Strips all whitespace from `text`, returning the compact string plus a + * same-length array mapping each compact-string index back to its index in + * the ORIGINAL `text`. Mirrors the TS `compact()`. */ +private fun compact(text: String): Pair { + val out = StringBuilder(text.length) + val toOriginal = ArrayList(text.length) + for (i in text.indices) { + if (text[i].isWhitespace()) continue + out.append(text[i]) + toOriginal.add(i) + } + return Pair(out.toString(), toOriginal.toIntArray()) +} + +/** Every non-overlapping occurrence of `exact` in `fullText`, whitespace + * differences ignored on both sides, as `[start, end)` offsets into the + * ORIGINAL `fullText`. Returns an empty list when `exact` (once whitespace + * is stripped) is empty or genuinely absent — the zero-occurrences case that + * becomes `null` (detached), never a thrown error. Mirrors the TS + * `findAllOccurrences`. */ +private fun findAllOccurrences(fullText: String, exact: String): List { + val needle = WHITESPACE.replace(exact, "") + if (needle.isEmpty()) return emptyList() + val (hay, toOriginal) = compact(fullText) + val results = mutableListOf() + var searchFrom = 0 + while (true) { + val idx = hay.indexOf(needle, searchFrom) + if (idx == -1) break + val start = toOriginal[idx] + // toOriginal[idx + needle.length - 1] is the ORIGINAL index of the + // match's last non-whitespace character; +1 makes `end` exclusive. + val end = toOriginal[idx + needle.length - 1] + 1 + results.add(ResolvedRange(start, end)) + searchFrom = idx + needle.length // non-overlapping + } + return results +} + +/** Levenshtein edit distance — the single scoring metric §2.2/review 2 (F14) + * specifies. Plain O(n·m) DP with a rolling pair of rows. Mirrors the TS + * `levenshtein`. */ +private fun levenshtein(a: String, b: String): Int { + if (a == b) return 0 + val m = a.length + val n = b.length + if (m == 0) return n + if (n == 0) return m + var prev = IntArray(n + 1) { it } + var curr = IntArray(n + 1) + for (i in 1..m) { + curr[0] = i + for (j in 1..n) { + val cost = if (a[i - 1] == b[j - 1]) 0 else 1 + curr[j] = minOf(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost) + } + val swap = prev + prev = curr + curr = swap + } + return prev[n] +} + +// F1 (perf, TS implementation review of T2) — ported unchanged: bounds how +// many candidates get scored at all (in document order) and how much of each +// comparison string is actually compared, so a pathological document (a huge +// repeated quote, or hundreds of matches of a short common phrase) doesn't +// make one anchoring lookup quadratic in either dimension. See +// doc-comments-anchor.ts's own doc comment on `resolveSelector` for the full +// reasoning; this Kotlin port keeps the identical constants so both runtimes' +// scoring agrees on which occurrences even get compared. +private const val MAX_SCORED_OCCURRENCES = 64 +private const val EXACT_SAMPLE_CHARS = 32 + +/** The first and last `n` characters of `text`, with the (possibly huge) + * middle dropped. Mirrors the TS `sampleEdges`. */ +private fun sampleEdges(text: String, n: Int): String { + if (text.length <= n * 2) return text + return text.substring(0, n) + text.substring(text.length - n) +} + +/** + * Finds where `sel` currently anchors in `fullText`, or reports that it no + * longer does (`null`, standing in for the TS `'detached'` sentinel — Kotlin + * has no string-literal union type to mirror it with). Mirrors + * doc-comments-anchor.ts's `resolveSelector` field-for-field: zero occurrences + * of `sel.exact` (whitespace-collapsed) -> `null`; exactly one -> that's the + * anchor, `sel.occurrence` never consulted; multiple -> score every + * (bounded) candidate by Levenshtein distance between + * (`sel.prefix` + sampled `sel.exact` + `sel.suffix`) and the document text + * surrounding that candidate (similarly sampled), take the lowest-distance + * one, first-in-document-order wins a genuine tie. + */ +fun resolveSelector(fullText: String, sel: TextQuoteSelector): ResolvedRange? { + val occurrences = findAllOccurrences(fullText, sel.exact) + if (occurrences.isEmpty()) return null + if (occurrences.size == 1) return occurrences[0] + + val wanted = sel.prefix + sampleEdges(sel.exact, EXACT_SAMPLE_CHARS) + sel.suffix + val candidates = if (occurrences.size > MAX_SCORED_OCCURRENCES) { + occurrences.subList(0, MAX_SCORED_OCCURRENCES) + } else { + occurrences + } + var best = candidates[0] + var bestScore = Int.MAX_VALUE + for (occ in candidates) { + val windowStart = maxOf(0, occ.start - sel.prefix.length) + val windowEnd = minOf(fullText.length, occ.end + sel.suffix.length) + val candidate = fullText.substring(windowStart, occ.start) + + sampleEdges(fullText.substring(occ.start, occ.end), EXACT_SAMPLE_CHARS) + + fullText.substring(occ.end, windowEnd) + val score = levenshtein(wanted, candidate) + if (score < bestScore) { + bestScore = score + best = occ + } + } + return best +} diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsBridge.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsBridge.kt new file mode 100644 index 000000000..7527d3551 --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsBridge.kt @@ -0,0 +1,337 @@ +// The docComments:* IPC dispatch logic, extracted out of +// SessionService.handleBridgeMessage's `when` block — F6 (T4 doc-comments +// implementation review): that block needed a running Android Service +// (bootstrap, sessionRegistry, bridgeServer) just to construct, which made it +// reachable only through the private `when` branches themselves — exercised +// end-to-end at best, never pinned by a direct assertion on the actual +// response JSON shape. This function takes exactly what those branches +// actually read off the Service — `homeDir` and the caller's own live +// session cwds — as plain parameters, so a JVM unit test +// (DocCommentsBridgeTest.kt) can drive every docComments:* message type and +// assert the REAL response object's keys/types/values, not a regex over a +// serialized string. +// +// SessionService.kt's own docComments branches now just compute `homeDir`/ +// `sessionRoots` from the running Service and call this function — see that +// file's own comment at the call site. +package com.youcoded.app.doccomments + +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * Handles one `docComments:*` bridge message and returns its response, or + * `null` for a `type` this function doesn't own (SessionService.kt's own + * `when` never reaches that case in practice — every docComments:* label it + * dispatches here is also matched below). Every returned response mirrors + * the exact envelope SessionService.kt's IPC branches produced before this + * extraction — same field names, same error codes — so this is a pure + * refactor, not a behavior change. + */ +suspend fun handleDocCommentsMessage( + type: String, + payload: JSONObject, + homeDir: File, + sessionRoots: List, +): JSONObject? { + val claudeDir = File(homeDir, ".claude") + + fun missingField(field: String) = JSONObject().put("ok", false).put("error", "missing-field").put("field", field) + fun unknownRoot() = JSONObject().put("ok", false).put("error", "unknown-project-root") + // T19 landed `.xlsx` writes for real — every docComments:* mutation now + // dispatches to a real reader/writer for both native formats, so this + // file no longer needs a "not yet supported" answer for either one. + // A live session's own cwd counts as a known root too (§1.4's synthetic- + // project fallback) — mirrors remote-server.ts's own sessionRoots(), so a + // file opened from an unregistered session's drawer doesn't start + // refusing. + fun gateRefused(projectRoot: String?) = refuseUnknownProjectRoot(projectRoot, homeDir, claudeDir, sessionRoots) + + return when (type) { + "docComments:list" -> { + val filePath = payload.optString("path", "") + if (filePath.isEmpty()) return missingField("path") + val projectRoot = payload.optString("projectRoot", "").ifEmpty { null } + if (gateRefused(projectRoot)) return unknownRoot() + val format = resolveNativeFormat(filePath, projectRoot, homeDir) + if (format != null) { + when (val r = listNativeComments(format, filePath, projectRoot, homeDir)) { + is NativeListResult.Ok -> JSONObject().put("ok", true).put("comments", JSONArray(r.comments.map { it.toJson() })) + is NativeListResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + } else { + when (val r = listComments(filePath, projectRoot, homeDir)) { + is StoreResult.Ok -> JSONObject().put("ok", true).put("comments", JSONArray(r.value.map { it.toJson() })) + is StoreResult.Err -> JSONObject().put("ok", false).put("error", r.error.wire) + } + } + } + + "docComments:add" -> { + val filePath = payload.optString("path", "") + if (filePath.isEmpty()) return missingField("path") + val projectRoot = payload.optString("projectRoot", "").ifEmpty { null } + if (gateRefused(projectRoot)) return unknownRoot() + val text = payload.optString("text", "") + if (text.isEmpty()) return missingField("text") + val selector = CommentSelector.fromJson(payload.optJSONObject("selector")) ?: return missingField("selector") + val author = payload.optString("author", "user") + // T17: a `.docx` target now writes for real, dispatched through + // DocCommentsDispatch.kt's own containment/allowlist gate — never + // the sidecar store, which has no row for a native comment (§1.1). + when (resolveNativeFormat(filePath, projectRoot, homeDir)) { + NativeFormat.DOCX -> when (val r = addNativeDocxComment(filePath, projectRoot, selector, text, author, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true).put("id", r.value) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + // T19: `.xlsx` writes for real now too. + NativeFormat.XLSX -> when (val r = addNativeXlsxComment(filePath, projectRoot, selector, text, author, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true).put("id", r.value) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + null -> { + // F4 (T5 review): the renderer mints and sends this now. + val callerId = payload.optString("id", "").ifEmpty { null } + when (val r = addComment(filePath, projectRoot, selector, text, author, homeDir, callerId)) { + is StoreResult.Ok -> JSONObject().put("ok", true).put("id", r.value) + is StoreResult.Err -> JSONObject().put("ok", false).put("error", r.error.wire) + } + } + } + } + + // reply/resolve/reopen/move ALL require `path` — the sidecar holding + // a given comment id can only be found by knowing the file (design + // review 3, F1), same as desktop's own IPC handlers. + "docComments:reply" -> { + val filePath = payload.optString("path", "") + if (filePath.isEmpty()) return missingField("path") + val projectRoot = payload.optString("projectRoot", "").ifEmpty { null } + if (gateRefused(projectRoot)) return unknownRoot() + val commentId = payload.optString("id", "") + if (commentId.isEmpty()) return missingField("id") + val text = payload.optString("text", "") + if (text.isEmpty()) return missingField("text") + val author = payload.optString("author", "user") + // T5 review parity (design §1.6, F2): `reply`'s response carries + // the real persisted `CommentReply` — the SAME enrichment + // desktop's own `docComments:reply` IPC response now returns, for + // both the native-format and plain-sidecar branches. + when (resolveNativeFormat(filePath, projectRoot, homeDir)) { + NativeFormat.DOCX -> when (val r = replyToNativeDocxComment(filePath, projectRoot, commentId, text, author, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true).put("reply", r.value.toJson()) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + NativeFormat.XLSX -> when (val r = replyToNativeXlsxComment(filePath, projectRoot, commentId, text, author, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true).put("reply", r.value.toJson()) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + null -> when (val r = replyToComment(filePath, projectRoot, commentId, text, author, homeDir)) { + is StoreResult.Ok -> JSONObject().put("ok", true).put("reply", r.value.toJson()) + is StoreResult.Err -> JSONObject().put("ok", false).put("error", r.error.wire) + } + } + } + + "docComments:resolve" -> { + val filePath = payload.optString("path", "") + if (filePath.isEmpty()) return missingField("path") + val projectRoot = payload.optString("projectRoot", "").ifEmpty { null } + if (gateRefused(projectRoot)) return unknownRoot() + val commentId = payload.optString("id", "") + if (commentId.isEmpty()) return missingField("id") + val by = payload.optString("by", "user") + when (resolveNativeFormat(filePath, projectRoot, homeDir)) { + NativeFormat.DOCX -> when (val r = resolveNativeDocxComment(filePath, projectRoot, commentId, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + NativeFormat.XLSX -> when (val r = resolveNativeXlsxComment(filePath, projectRoot, commentId, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + null -> when (val r = resolveComment(filePath, projectRoot, commentId, by, homeDir)) { + is StoreResult.Ok -> JSONObject().put("ok", true) + is StoreResult.Err -> JSONObject().put("ok", false).put("error", r.error.wire) + } + } + } + + "docComments:reopen" -> { + val filePath = payload.optString("path", "") + if (filePath.isEmpty()) return missingField("path") + val projectRoot = payload.optString("projectRoot", "").ifEmpty { null } + if (gateRefused(projectRoot)) return unknownRoot() + val commentId = payload.optString("id", "") + if (commentId.isEmpty()) return missingField("id") + val by = payload.optString("by", "user") + when (resolveNativeFormat(filePath, projectRoot, homeDir)) { + NativeFormat.DOCX -> when (val r = reopenNativeDocxComment(filePath, projectRoot, commentId, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + NativeFormat.XLSX -> when (val r = reopenNativeXlsxComment(filePath, projectRoot, commentId, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + null -> when (val r = reopenComment(filePath, projectRoot, commentId, by, homeDir)) { + is StoreResult.Ok -> JSONObject().put("ok", true) + is StoreResult.Err -> JSONObject().put("ok", false).put("error", r.error.wire) + } + } + } + + "docComments:move" -> { + val filePath = payload.optString("path", "") + if (filePath.isEmpty()) return missingField("path") + val projectRoot = payload.optString("projectRoot", "").ifEmpty { null } + if (gateRefused(projectRoot)) return unknownRoot() + val commentId = payload.optString("id", "") + if (commentId.isEmpty()) return missingField("id") + val newSelector = CommentSelector.fromJson(payload.optJSONObject("newSelector")) ?: return missingField("newSelector") + when (resolveNativeFormat(filePath, projectRoot, homeDir)) { + NativeFormat.DOCX -> when (val r = moveNativeDocxComment(filePath, projectRoot, commentId, newSelector, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + // Coordinator review fix: desktop's own `docComments:move` IPC + // response for an xlsx target carries the moved thread's + // FRESH id (`moveNativeXlsxComment` in doc-comments- + // dispatch.ts returns `{ok:true, id: string}`, confirmed + // directly against that file — §4.2/§4.3's "never guessing at + // a stale hint" reasoning: a moved thread's OLD id's embedded- + // cell hint goes stale the instant it moves, so the very next + // call against the SAME thread needs the fresh one to skip a + // full-workbook fallback scan). Docx's own move never changes + // its id (a `TextQuoteSelector`-anchored comment keeps the + // same `w:id`), so ONLY the xlsx branch carries `id` here — + // this is a real, intentional per-format asymmetry in + // desktop's own shape, not an inconsistency to "fix" away. + NativeFormat.XLSX -> when (val r = moveNativeXlsxComment(filePath, projectRoot, commentId, newSelector, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true).put("id", r.value) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + null -> when (val r = moveComment(filePath, projectRoot, commentId, newSelector, homeDir)) { + is StoreResult.Ok -> JSONObject().put("ok", true) + is StoreResult.Err -> JSONObject().put("ok", false).put("error", r.error.wire) + } + } + } + + // Edit/delete build (2026-09-28, design doc §"Edit and delete") — same + // containment/format-dispatch shape as reply/resolve/reopen/move + // above. Decisions.json: anyone's comment/reply can be edited or + // deleted; no "edited" marker is stored or shown; deleting a + // thread's first comment deletes the whole thread. Mirrors desktop's + // own `DOC_COMMENTS_IPC.EDIT`/`EDIT_REPLY`/`DELETE`/`DELETE_REPLY` + // handler blocks (ipc-handlers.ts). + "docComments:edit" -> { + val filePath = payload.optString("path", "") + if (filePath.isEmpty()) return missingField("path") + val projectRoot = payload.optString("projectRoot", "").ifEmpty { null } + if (gateRefused(projectRoot)) return unknownRoot() + val commentId = payload.optString("id", "") + if (commentId.isEmpty()) return missingField("id") + val text = payload.optString("text", "") + if (text.isEmpty()) return missingField("text") + when (resolveNativeFormat(filePath, projectRoot, homeDir)) { + NativeFormat.DOCX -> when (val r = editNativeDocxComment(filePath, projectRoot, commentId, text, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + NativeFormat.XLSX -> when (val r = editNativeXlsxComment(filePath, projectRoot, commentId, text, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + null -> when (val r = editComment(filePath, projectRoot, commentId, text, homeDir)) { + is StoreResult.Ok -> JSONObject().put("ok", true) + is StoreResult.Err -> JSONObject().put("ok", false).put("error", r.error.wire) + } + } + } + + "docComments:edit-reply" -> { + val filePath = payload.optString("path", "") + if (filePath.isEmpty()) return missingField("path") + val projectRoot = payload.optString("projectRoot", "").ifEmpty { null } + if (gateRefused(projectRoot)) return unknownRoot() + val commentId = payload.optString("id", "") + if (commentId.isEmpty()) return missingField("id") + val replyId = payload.optString("replyId", "") + if (replyId.isEmpty()) return missingField("replyId") + val text = payload.optString("text", "") + if (text.isEmpty()) return missingField("text") + when (resolveNativeFormat(filePath, projectRoot, homeDir)) { + NativeFormat.DOCX -> when (val r = editNativeDocxReply(filePath, projectRoot, commentId, replyId, text, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true).put("reply", r.value.toJson()) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + NativeFormat.XLSX -> when (val r = editNativeXlsxReply(filePath, projectRoot, commentId, replyId, text, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true).put("reply", r.value.toJson()) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + null -> when (val r = editReply(filePath, projectRoot, commentId, replyId, text, homeDir)) { + is StoreResult.Ok -> JSONObject().put("ok", true).put("reply", r.value.toJson()) + is StoreResult.Err -> JSONObject().put("ok", false).put("error", r.error.wire) + } + } + } + + "docComments:delete" -> { + val filePath = payload.optString("path", "") + if (filePath.isEmpty()) return missingField("path") + val projectRoot = payload.optString("projectRoot", "").ifEmpty { null } + if (gateRefused(projectRoot)) return unknownRoot() + val commentId = payload.optString("id", "") + if (commentId.isEmpty()) return missingField("id") + when (resolveNativeFormat(filePath, projectRoot, homeDir)) { + NativeFormat.DOCX -> when (val r = deleteNativeDocxComment(filePath, projectRoot, commentId, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + NativeFormat.XLSX -> when (val r = deleteNativeXlsxComment(filePath, projectRoot, commentId, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + null -> when (val r = deleteComment(filePath, projectRoot, commentId, homeDir)) { + is StoreResult.Ok -> JSONObject().put("ok", true) + is StoreResult.Err -> JSONObject().put("ok", false).put("error", r.error.wire) + } + } + } + + "docComments:delete-reply" -> { + val filePath = payload.optString("path", "") + if (filePath.isEmpty()) return missingField("path") + val projectRoot = payload.optString("projectRoot", "").ifEmpty { null } + if (gateRefused(projectRoot)) return unknownRoot() + val commentId = payload.optString("id", "") + if (commentId.isEmpty()) return missingField("id") + val replyId = payload.optString("replyId", "") + if (replyId.isEmpty()) return missingField("replyId") + when (resolveNativeFormat(filePath, projectRoot, homeDir)) { + NativeFormat.DOCX -> when (val r = deleteNativeDocxReply(filePath, projectRoot, commentId, replyId, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + NativeFormat.XLSX -> when (val r = deleteNativeXlsxReply(filePath, projectRoot, commentId, replyId, homeDir)) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + null -> when (val r = deleteReply(filePath, projectRoot, commentId, replyId, homeDir)) { + is StoreResult.Ok -> JSONObject().put("ok", true) + is StoreResult.Err -> JSONObject().put("ok", false).put("error", r.error.wire) + } + } + } + + // Watching is the one piece that still follows Git's "absent, not + // reimplemented" precedent, for EVERY file type (§1.6): Android has + // no FileObserver-based watch today — a general no-push gap unrelated + // to reopen-1's promise about read/add/reply/resolve. + "docComments:watch", "docComments:unwatch" -> JSONObject().put("ok", false).put("error", "not-implemented-on-mobile") + + else -> null + } +} diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsDispatch.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsDispatch.kt new file mode 100644 index 000000000..74f769c9a --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsDispatch.kt @@ -0,0 +1,609 @@ +// The docComments:* dispatch point — Android half of T4 (docs/active/specs/ +// 2026-09-26-doc-comments-build-design.md §1.1, §3.2a, §4.3a). Word (.docx) +// and Excel (.xlsx) files do NOT get a `PersistedComment` sidecar row — their +// comments live inside the file itself. Mirrors desktop's +// doc-comments-dispatch.ts: the ONE place that decides, by extension, which +// files are native-format vs. sidecar-backed, reused by every channel branch +// in SessionService.kt so it can never disagree with itself about which +// files are which. +// +// READ is real: `listNativeComments` hands the resolved, containment-checked +// file straight to T16/T18's own `readDocxComments`/`readXlsxComments` +// (`DocxComments.kt`/`XlsxComments.kt` — not edited by this task). +// +// F3 (T4 implementation review, major): `listNativeComments` used to call +// those readers with NO exception boundary of its own. Both readers already +// catch their two KNOWN throwing signals (an unsafe-XML DOCTYPE, a +// decompression-bomb) — see their own doc comments — but a corrupt-but- +// openable archive (valid ZIP central directory, malformed inner XML, a +// truncated compressed entry, an unexpected structure) throws something +// else entirely (SAXException, IOException from a bad deflate stream, a +// parsing NPE/NumberFormatException…), which escaped BOTH readers uncaught, +// then `SessionService.handleBridgeMessage`'s own `serviceScope.launch { }` +// (no try/catch of its own), leaving the WebView's request unanswered +// forever rather than refused. `listNativeComments` below now catches at +// this dispatch boundary and returns a typed refusal — the SAME shape every +// other refusal in this module already uses — distinguishing a genuine I/O +// failure (permission denied, or the file vanishing between the containment +// check and the read: `read-failed`, matching this module's own existing +// code for an unreadable/missing file) from a corrupt archive's CONTENT +// (everything else: `invalid-docx`/`invalid-xlsx`, the SAME wire code the +// reader itself would have produced had `ZipFile` failed to open at all). +// +// WRITE (add/reply/resolve/reopen/move): T17 wired the `.docx` half into +// `DocxComments.kt`'s own write functions; T19 (this task) wires the `.xlsx` +// half into `XlsxComments.kt`'s own write functions the identical way — Excel +// comment mutation on Android is now REAL too, dispatched through the exact +// same containment/allowlist gates `listNativeComments` already uses for +// reads (a write is at least as sensitive as a read, never a looser gate). +// `refuseNativeMutation` is now permanently `false` — mirrors desktop's own +// `refuseNativeMutation`, also permanently null there since both its formats +// shipped — kept as a single named function (rather than deleted outright) so +// a future new native format has one place to wire a refusal into, the same +// reasoning that already applied to it before both formats landed. +package com.youcoded.app.doccomments + +import java.io.File + +enum class NativeFormat { DOCX, XLSX } + +/** Extension-based dispatch decision — the ONE place that decides "does this + * path have its comments inside the file itself." */ +fun nativeFormatFor(filePath: String): NativeFormat? = when (File(filePath).extension.lowercase()) { + "docx" -> NativeFormat.DOCX + "xlsx" -> NativeFormat.XLSX + else -> null +} + +/** + * Review finding #5 (docs/active/reviews/2026-09-27-doc-comments-t9ab- + * review.md): `nativeFormatFor` alone decides "is this a Word/Excel file" + * purely from the caller-supplied path string — a `.txt` symlink pointing at + * a real `.docx` extension-matches as plain text, so its comment would + * silently land in the inert JSON sidecar instead of the real document. + * Mirrors desktop's own `resolveNativeFormat` (doc-comments-dispatch.ts): + * decides from the SAME resolved path `resolveSourceFilePath` already + * computes (realpath, following any symlink, inside a known project or as + * an allowlisted untracked file), never the caller's unresolved string. + * + * Deliberately forgiving on failure: if resolution refuses for any reason + * (outside the project, doesn't exist, not tracked/allowlisted), this + * returns `null` (never "native") rather than surfacing that refusal + * itself — `DocCommentsBridge.kt`'s own subsequent `listNativeComments`/ + * `addNativeDocxComment`/etc. call still runs its OWN resolve step right + * afterward and produces the exact same typed refusal it always did. This + * function's only job is picking the RIGHT dispatch branch; it authorizes + * nothing on its own, and path containment is still checked exactly where + * it already was. + */ +fun resolveNativeFormat(path: String, projectRoot: String?, homeDir: File): NativeFormat? { + val resolved = resolveSourceFilePath(path, projectRoot, homeDir) + if (resolved !is StoreResult.Ok) return null + return nativeFormatFor(resolved.value) +} + +sealed class NativeListResult { + data class Ok(val comments: List) : NativeListResult() + data class Err(val error: String) : NativeListResult() +} + +/** + * `docComments:list` for a `.docx`/`.xlsx` target. Resolves the SOURCE file's + * own containment-verified absolute path (never a sidecar), reads its bytes + * via T16/T18's own file-based reader (no in-memory byte hop over IPC — this + * Kotlin code already has direct filesystem access, unlike a renderer that + * needs `artifacts:read-binary` to get bytes at all). + * + * F4 (T4 implementation review, major/security — supersedes this function's + * original denylist-only design): a no-`projectRoot` native read used to + * check ONLY `EditablePathPolicy.isSensitivePath` — a DENYLIST of well-known + * secret locations, refusing nothing else. That was a stand-in for desktop's + * real authority (`authorizeBytesRead`, `read-service.ts`), not a match for + * it, and the gap widens as more native tools gain model-controlled path + * arguments (§1.5's own "a new tool surface must implement its own + * containment check, it inherits none" applies here too) — a caller naming + * ANY path outside the few denied segments read straight through. This now + * checks the SAME two-pass ALLOWLIST desktop's `evaluateBinaryRead` + * (`read-binary-access.ts`) uses instead, via `DocCommentsGate.kt`'s + * `allowUntrackedNativeRead` — the app's own known project roots (or a + * descendant of one) OR a path recorded as a tracked EXTERNAL artifact/ + * manual include in one of those projects' own sidecars (a temp-dir + * spreadsheet the session drawer legitimately shows lives outside every + * root) — with the sensitive-path denylist kept as a FIRST-pass refusal even + * inside an otherwise-allowed root, exactly how `evaluateBinaryRead` orders + * its own two checks (defense in depth, not a replacement for one or the + * other). Deliberately excludes live session cwds, unlike + * `refuseUnknownProjectRoot`'s own roots — see `allowUntrackedNativeRead`'s + * own doc comment for why. + * + * A prior implementation review's own F4 — threading contract (unrelated to + * this T4 review's F4 above; finding numbers reset per review round): + * `readDocxComments`/ + * `readXlsxComments` (called below) are plain, BLOCKING, synchronous + * functions — not `suspend` — and perform real file/zip I/O directly on the + * calling thread. This is safe ONLY because of THIS function's own caller: + * `SessionService.handleBridgeMessage` is always invoked as + * `serviceScope.launch { handleBridgeMessage(ws, msg) }` (SessionService.kt's + * `onCreate()`), and `serviceScope` is + * `CoroutineScope(Dispatchers.IO + SupervisorJob())` — so EVERY + * `docComments:*` branch, including this function's own call into T16/T18's + * readers, already runs on `Dispatchers.IO` by the time it gets here, never + * on `Dispatchers.Main`/the UI thread. Making these readers `suspend fun` + + * wrapping their bodies in their own `withContext(Dispatchers.IO)` would be + * redundant (an extra dispatch hop onto a dispatcher the call is already + * running on) rather than a real safety improvement — this doc comment + * records that contract explicitly instead, so a future caller added OUTSIDE + * `handleBridgeMessage` doesn't assume these functions are main-thread-safe + * just because they carry no `Dispatchers.IO` mention of their own. + */ +fun listNativeComments(format: NativeFormat, path: String, projectRoot: String?, homeDir: File): NativeListResult { + val resolved = resolveSourceFilePath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return NativeListResult.Err(resolved.error.wire) + val absolutePath = (resolved as StoreResult.Ok).value + if (projectRoot.isNullOrEmpty()) { + // F4: allowlist (known roots + tracked/opened files), not a denylist + // — see this function's own doc comment above. + if (!allowUntrackedNativeRead(absolutePath, homeDir, File(homeDir, ".claude"))) { + return NativeListResult.Err("path-not-tracked") + } + } + val file = File(absolutePath) + if (!file.exists()) return NativeListResult.Err("read-failed") + // F3: every reader call is now wrapped — see this function's own doc + // comment above for the read-failed vs. invalid/corrupt distinction. + return try { + when (format) { + NativeFormat.DOCX -> when (val r = readDocxComments(file, path)) { + is DocxReadResult.Ok -> NativeListResult.Ok(r.comments) + is DocxReadResult.Err -> NativeListResult.Err(r.error.name.lowercase().replace('_', '-')) + } + NativeFormat.XLSX -> when (val r = readXlsxComments(file, path)) { + is XlsxReadResult.Ok -> NativeListResult.Ok(r.comments) + is XlsxReadResult.Err -> NativeListResult.Err(r.error.name.lowercase().replace('_', '-')) + } + } + } catch (_: SecurityException) { + NativeListResult.Err("read-failed") + } catch (_: java.io.FileNotFoundException) { + NativeListResult.Err("read-failed") + } catch (_: Exception) { + // Everything else (malformed XML, a truncated compressed entry, an + // unexpected structure a legitimate Word/Excel file never produces) + // is the archive's CONTENT being corrupt, not an I/O failure — the + // SAME wire code the reader itself would have returned had `ZipFile` + // failed to open at all. `Exception`, never `Throwable` — an OOM or + // stack overflow is a real crash this boundary should not mask. + NativeListResult.Err( + when (format) { + NativeFormat.DOCX -> DocxReadError.INVALID_DOCX.name.lowercase().replace('_', '-') + NativeFormat.XLSX -> XlsxReadError.INVALID_XLSX.name.lowercase().replace('_', '-') + }, + ) + } +} + +/** T19: `.xlsx` writes are real now too (docx already was, T17) — neither + * native format refuses a mutation any more. Kept as a single named function + * (mirroring desktop's own `refuseNativeMutation`, also permanently null + * there) rather than deleted outright, so a FUTURE new native format has one + * place to wire a refusal into instead of a per-channel special case. */ +fun refuseNativeMutation(filePath: String): Boolean = false + +/** Generic Ok/Err result for a `.docx`/`.xlsx` WRITE dispatch — mirrors + * `NativeListResult` above, kept generic (unlike that one) because `add` + * returns a new comment id while reply/resolve/reopen/move return nothing. */ +sealed class NativeMutateResult { + data class Ok(val value: T) : NativeMutateResult() + data class Err(val error: String) : NativeMutateResult() +} + +/** + * Resolves a `.docx`/`.xlsx` mutation's target the SAME way `listNativeComments` + * resolves its read target: `resolveSourceFilePath`'s containment check when + * `projectRoot` is given (already vetted by `refuseUnknownProjectRoot` at the + * bridge layer before dispatch is ever reached — see that function's own doc + * comment), or `allowUntrackedNativeRead`'s allowlist when it isn't. A write + * is at least as sensitive as a read, so it gets the exact same gate, never a + * looser one. Mirrors desktop's `resolveDocxTarget`/`resolveXlsxTarget` + * (doc-comments-dispatch.ts). + */ +private fun resolveNativeWriteTarget(path: String, projectRoot: String?, homeDir: File): NativeMutateResult { + val resolved = resolveSourceFilePath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return NativeMutateResult.Err(resolved.error.wire) + val absolutePath = (resolved as StoreResult.Ok).value + if (projectRoot.isNullOrEmpty()) { + if (!allowUntrackedNativeRead(absolutePath, homeDir, File(homeDir, ".claude"))) { + return NativeMutateResult.Err("path-not-tracked") + } + } + return NativeMutateResult.Ok(absolutePath) +} + +// F2 (T17 implementation review, major/crash risk): every write dispatch +// function below now wraps its call into DocxComments.kt's own write +// pipeline in the SAME exception boundary `listNativeComments` already has +// for reads (see that function's own doc comment, F3, T4 review) — a +// corrupt-but-openable archive (a bad `w:id` that fails `.toIntOrNull()` +// somewhere this module doesn't already guard, a truncated deflate stream +// mid-write, an XML shape `javax.xml.parsers`' strict parser refuses that +// `readDocxComments` never needed to reach) can throw something other than +// the two typed exceptions `loadArchiveForWrite`/`writeDocxMutation` already +// catch (`DocxUnsafeXmlDoctypeException`, `ZipBombDetectedException`) — and +// before this fix, that throw escaped uncaught through this dispatch +// function, then `SessionService`'s own `serviceScope.launch { }` (no +// CoroutineExceptionHandler installed on that scope), which is a PROCESS +// CRASH on Android, not just a dropped response. `SecurityException`/ +// `FileNotFoundException` map to `read-failed` (the file vanished or became +// unreadable between the containment check and the write, an I/O failure); +// everything else maps to `invalid-docx` — the SAME wire code +// `loadArchiveForWrite` itself already returns when `ZipFile(file)` fails to +// open at all, so a caller sees one consistent code for "this archive's +// content is unusable," not a leaked stack trace. `Exception`, never +// `Throwable` — an OOM or stack overflow is a real crash this boundary must +// not mask, same reasoning as `listNativeComments`'s own catch. +// T19: `invalidFormatWire` is now a parameter, not a hardcoded +// `DocxWriteError.INVALID_DOCX.wire` — this boundary is shared by BOTH +// native formats' dispatch functions, and reporting "invalid-docx" for a +// corrupt `.xlsx` (the wire code every xlsx call site got before this fix) +// would be a wrong, misleading refusal, not just an imprecise one. Defaults +// to docx's own wire code so every existing `.docx` call site above is +// unaffected; the new `.xlsx` dispatch functions below pass +// `XlsxWriteError.INVALID_XLSX.wire` explicitly. +private suspend fun nativeMutateExceptionBoundary( + invalidFormatWire: String = DocxWriteError.INVALID_DOCX.wire, + block: suspend () -> NativeMutateResult, +): NativeMutateResult = try { + block() +} catch (_: SecurityException) { + NativeMutateResult.Err(DocxWriteError.READ_FAILED.wire) +} catch (_: java.io.FileNotFoundException) { + NativeMutateResult.Err(DocxWriteError.READ_FAILED.wire) +} catch (_: Exception) { + NativeMutateResult.Err(invalidFormatWire) +} + +/** T17: Android's real `.docx` write dispatch — the Kotlin equivalent of + * desktop's `addNativeDocxComment`/etc. (doc-comments-dispatch.ts), calling + * straight into `DocxComments.kt`'s own write pipeline (§3.2a/§3.3). */ +suspend fun addNativeDocxComment( + path: String, + projectRoot: String?, + selector: CommentSelector, + text: String, + author: CommentAuthor, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary { + when (val r = addDocxComment(absolutePath, path, selector, text, author, homeDir)) { + is DocxWriteResult.Ok -> NativeMutateResult.Ok(r.value) + is DocxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +/** T5 review parity (design §1.6, F2): returns the real persisted + * `CommentReply` — the SAME enrichment desktop's own `docComments:reply` + * IPC response carries, so `DocCommentsBridge.kt`'s wire JSON stays in + * parity with desktop/remote. */ +suspend fun replyToNativeDocxComment( + path: String, + projectRoot: String?, + id: String, + text: String, + author: CommentAuthor, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary { + when (val r = replyToDocxComment(absolutePath, path, id, text, author, homeDir)) { + is DocxWriteResult.Ok -> NativeMutateResult.Ok(r.value) + is DocxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +/** `by` accepted for call-site symmetry with the generic `{path, id, by}` + * payload but not forwarded — see `resolveDocxComment`'s own doc comment + * (DocxComments.kt) for why a native Word comment has nowhere to record it. */ +suspend fun resolveNativeDocxComment(path: String, projectRoot: String?, id: String, homeDir: File): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary { + when (val r = resolveDocxComment(absolutePath, path, id, homeDir)) { + is DocxWriteResult.Ok -> NativeMutateResult.Ok(Unit) + is DocxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +suspend fun reopenNativeDocxComment(path: String, projectRoot: String?, id: String, homeDir: File): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary { + when (val r = reopenDocxComment(absolutePath, path, id, homeDir)) { + is DocxWriteResult.Ok -> NativeMutateResult.Ok(Unit) + is DocxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +suspend fun moveNativeDocxComment( + path: String, + projectRoot: String?, + id: String, + newSelector: CommentSelector, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary { + when (val r = moveDocxComment(absolutePath, path, id, newSelector, homeDir)) { + is DocxWriteResult.Ok -> NativeMutateResult.Ok(Unit) + is DocxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +// Edit/delete build (2026-09-28, design doc §"Edit and delete") — same +// resolveNativeWriteTarget gate every other native docx mutation already +// uses. Mirrors desktop's own `editNativeDocxComment`/etc. (doc-comments- +// dispatch.ts). +suspend fun editNativeDocxComment( + path: String, + projectRoot: String?, + id: String, + text: String, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary { + when (val r = editDocxComment(absolutePath, path, id, text, homeDir)) { + is DocxWriteResult.Ok -> NativeMutateResult.Ok(r.value) + is DocxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +suspend fun editNativeDocxReply( + path: String, + projectRoot: String?, + id: String, + replyId: String, + text: String, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary { + when (val r = editDocxReply(absolutePath, path, id, replyId, text, homeDir)) { + is DocxWriteResult.Ok -> NativeMutateResult.Ok(r.value) + is DocxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +suspend fun deleteNativeDocxComment(path: String, projectRoot: String?, id: String, homeDir: File): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary { + when (val r = deleteDocxComment(absolutePath, path, id, homeDir)) { + is DocxWriteResult.Ok -> NativeMutateResult.Ok(Unit) + is DocxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +suspend fun deleteNativeDocxReply( + path: String, + projectRoot: String?, + id: String, + replyId: String, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary { + when (val r = deleteDocxReply(absolutePath, path, id, replyId, homeDir)) { + is DocxWriteResult.Ok -> NativeMutateResult.Ok(Unit) + is DocxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +// ----------------------------------------------------------------------- +// T19: Android's real `.xlsx` write dispatch — the Kotlin equivalent of +// desktop's `addNativeXlsxComment`/etc. (doc-comments-dispatch.ts), calling +// straight into `XlsxComments.kt`'s own write pipeline (§4.3/§4.3a). Mirrors +// the `.docx` dispatch functions above field-for-field; kept as its own +// exception-boundary wrapper (`XlsxWriteError.READ_FAILED`/`INVALID_XLSX` +// wire codes, not docx's) for the identical reason `nativeMutateExceptionBoundary` +// itself is generic over `NativeMutateResult` — a corrupt-but-openable +// xlsx archive can throw something other than this module's own typed +// exceptions (already caught one layer in, inside `writeXlsxMutation` itself) +// before ever reaching a typed `XlsxWriteResult`, and this boundary is what +// turns THAT into a clean refusal instead of a process crash, the same +// reasoning F2 (T17 implementation review) already established for docx. +// ----------------------------------------------------------------------- + +suspend fun addNativeXlsxComment( + path: String, + projectRoot: String?, + selector: CommentSelector, + text: String, + author: CommentAuthor, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary(XlsxWriteError.INVALID_XLSX.wire) { + when (val r = addXlsxComment(absolutePath, path, selector, text, author, homeDir)) { + is XlsxWriteResult.Ok -> NativeMutateResult.Ok(r.value) + is XlsxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +suspend fun replyToNativeXlsxComment( + path: String, + projectRoot: String?, + id: String, + text: String, + author: CommentAuthor, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary(XlsxWriteError.INVALID_XLSX.wire) { + when (val r = replyToXlsxComment(absolutePath, path, id, text, author, homeDir)) { + is XlsxWriteResult.Ok -> NativeMutateResult.Ok(r.value) + is XlsxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +/** `by` accepted for call-site symmetry but not forwarded — see + * `resolveXlsxComment`'s own doc comment (XlsxComments.kt). */ +suspend fun resolveNativeXlsxComment(path: String, projectRoot: String?, id: String, homeDir: File): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary(XlsxWriteError.INVALID_XLSX.wire) { + when (val r = resolveXlsxComment(absolutePath, path, id, homeDir)) { + is XlsxWriteResult.Ok -> NativeMutateResult.Ok(Unit) + is XlsxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +suspend fun reopenNativeXlsxComment(path: String, projectRoot: String?, id: String, homeDir: File): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary(XlsxWriteError.INVALID_XLSX.wire) { + when (val r = reopenXlsxComment(absolutePath, path, id, homeDir)) { + is XlsxWriteResult.Ok -> NativeMutateResult.Ok(Unit) + is XlsxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +/** T19's own `MoveComment` — returns the moved thread's FRESH id (§4.2/§4.3's + * own "never guessing at a stale hint" reasoning), unlike docx's move (a + * `TextQuoteSelector`-anchored comment's own id never changes on move). + * + * Code review 2026-09-27, Android F2: the previous version of this comment + * claimed the IPC response stays `{ok:true}` either way and that no caller + * reads a move's id back — both false. `DocCommentsBridge.kt`'s direct + * `move` dispatch DOES put `id` on the JSON response for the xlsx branch + * (`.put("id", r.value)`, right next to its own "Coordinator review fix" + * comment); `DocCommentsPendingQueue.kt`'s `move` branch now ALSO forwards + * it (fixed alongside this comment, mirroring desktop's own + * pending-mutation-queue.ts fix from the SAME review round — before this, + * Android's queue silently dropped it even after desktop's did not, + * reintroducing the exact staleness bug across the assistant's MCP path). + * Both real callers now read the fresh id; only a caller that never + * bothered to read the response at all would still pay for the + * full-workbook fallback scan on its next lookup. */ +suspend fun moveNativeXlsxComment( + path: String, + projectRoot: String?, + id: String, + newSelector: CommentSelector, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary(XlsxWriteError.INVALID_XLSX.wire) { + when (val r = moveXlsxComment(absolutePath, path, id, newSelector, homeDir)) { + is XlsxWriteResult.Ok -> NativeMutateResult.Ok(r.value) + is XlsxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +// Edit/delete build (2026-09-28, design doc §"Edit and delete") — same +// resolveNativeWriteTarget gate every other native xlsx mutation already +// uses. Mirrors desktop's own `editNativeXlsxComment`/etc. (doc-comments- +// dispatch.ts). +suspend fun editNativeXlsxComment( + path: String, + projectRoot: String?, + id: String, + text: String, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary(XlsxWriteError.INVALID_XLSX.wire) { + when (val r = editXlsxComment(absolutePath, path, id, text, homeDir)) { + is XlsxWriteResult.Ok -> NativeMutateResult.Ok(r.value) + is XlsxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +suspend fun editNativeXlsxReply( + path: String, + projectRoot: String?, + id: String, + replyId: String, + text: String, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary(XlsxWriteError.INVALID_XLSX.wire) { + when (val r = editXlsxReply(absolutePath, path, id, replyId, text, homeDir)) { + is XlsxWriteResult.Ok -> NativeMutateResult.Ok(r.value) + is XlsxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +suspend fun deleteNativeXlsxComment(path: String, projectRoot: String?, id: String, homeDir: File): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary(XlsxWriteError.INVALID_XLSX.wire) { + when (val r = deleteXlsxComment(absolutePath, path, id, homeDir)) { + is XlsxWriteResult.Ok -> NativeMutateResult.Ok(Unit) + is XlsxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} + +suspend fun deleteNativeXlsxReply( + path: String, + projectRoot: String?, + id: String, + replyId: String, + homeDir: File, +): NativeMutateResult { + val resolved = resolveNativeWriteTarget(path, projectRoot, homeDir) + if (resolved is NativeMutateResult.Err) return resolved + val absolutePath = (resolved as NativeMutateResult.Ok).value + return nativeMutateExceptionBoundary(XlsxWriteError.INVALID_XLSX.wire) { + when (val r = deleteXlsxReply(absolutePath, path, id, replyId, homeDir)) { + is XlsxWriteResult.Ok -> NativeMutateResult.Ok(Unit) + is XlsxWriteResult.Err -> NativeMutateResult.Err(r.error.wire) + } + } +} diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsGate.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsGate.kt new file mode 100644 index 000000000..9c36b8ecd --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsGate.kt @@ -0,0 +1,134 @@ +// Shared `projectRoot` gate for Android's docComments:* surface — T4 of the +// doc-comments build (docs/active/specs/2026-09-26-doc-comments-build- +// design.md §1.5). Kotlin port of desktop/src/main/doc-comments/doc-comments- +// gate.ts's `refuseUnknownProjectRoot`, using Android's OWN notion of a +// "known project root" rather than desktop's `isKnownRoot()` (which reads a +// central index + saved-folders store that only exists in the Electron main +// process) — the same authority `WorkingDirStore`/`CentralIndex.listProjects` +// already give `artifacts:get`/`artifacts:save`'s own relative-record judging +// (ProjectManager.kt's `judgeRelativeRecord`) on this platform. +// +// WHY this exists (mirrors the TS module's own WHY, T3 build review F1 — +// blocker, security): doc-comments-store.ts's containment check only proves +// a `path` resolves INSIDE whatever `projectRoot` it is given — nothing +// proves `projectRoot` itself is a folder the app actually recognizes. A +// caller naming `projectRoot: "/"` (or the phone's home directory) would +// make that containment check a no-op, so every docComments:* channel could +// create/mutate a sidecar anywhere on disk, and a `.docx`/`.xlsx` `list` +// could read and parse any such file on the device. This module is the ONE +// place that decision is made, reused identically by every channel's `when` +// branch in SessionService.kt — never a per-channel re-derivation. +// +// `extraSessionRoots` mirrors remote-server.ts's own `sessionRoots()` / +// desktop's `useActiveProject.ts` synthetic-project fallback (§1.4): a live +// session's own cwd counts as known even when it was never saved as a +// working directory or indexed, so a file opened from an unregistered +// session's drawer doesn't start refusing. The caller (SessionService.kt) +// supplies its OWN live session cwds; this module never discovers them. +package com.youcoded.app.doccomments + +import com.youcoded.app.artifacts.EditablePathPolicy +import com.youcoded.app.artifacts.ReadResult +import com.youcoded.app.artifacts.canonicalize +import com.youcoded.app.artifacts.listProjects +import com.youcoded.app.artifacts.readSidecar +import com.youcoded.app.config.WorkingDirStore +import java.io.File + +/** Canonicalizes `path` two ways — string-normalized (`canonicalize`, no fs + * touch) and filesystem-real (`File.canonicalPath`, follows symlinks) — + * mirroring desktop's `withRealForms`. Either form matching a known root is + * enough: a caller may spell the same root either way. */ +private fun realForms(path: String): Set { + val out = mutableSetOf() + if (path.isEmpty()) return out + out.add(canonicalize(path, null)) + try { + out.add(canonicalize(File(path).canonicalPath, null)) + } catch (_: java.io.IOException) { + // Doesn't exist / unresolvable — the string form above still applies. + } + return out +} + +/** The device's known project roots: saved working directories + + * central-index projects, mirroring desktop's own `knownRoots()` + * (readFolders() + listProjects(CLAUDE_DIR)) — plus the caller's own live + * session cwds, which the TS side folds in as `extraRoots` at the call + * site rather than discovering independently. */ +private fun knownRoots(homeDir: File, claudeDir: File, extraSessionRoots: List): Set { + val saved = WorkingDirStore(homeDir).dirs.value.map { it.path } + val indexed = listProjects(claudeDir.path).map { it.path } + val all = saved + indexed + extraSessionRoots + val out = mutableSetOf() + for (root in all) out.addAll(realForms(root)) + return out +} + +/** + * Refuses when `projectRoot` is present but is not a root the app itself + * shows — a saved working directory, an indexed project, or one of + * `extraSessionRoots`. Returns `null` (proceed) when `projectRoot` is null or + * empty: the caller then takes the fallback (loose-file) path, which never + * claims a project at all (§1.4) and is gated separately, at the point it + * would read a file's actual bytes (see DocCommentsDispatch.kt). + */ +fun refuseUnknownProjectRoot( + projectRoot: String?, + homeDir: File, + claudeDir: File, + extraSessionRoots: List = emptyList(), +): Boolean { + if (projectRoot.isNullOrEmpty()) return false + val known = knownRoots(homeDir, claudeDir, extraSessionRoots) + return realForms(projectRoot).none { known.contains(it) } +} + +/** + * F4 (T4 implementation review, major/security): the allowlist a + * no-`projectRoot` native (`.docx`/`.xlsx`) read now checks — replacing that + * read's own former denylist-only design (see `DocCommentsDispatch.kt`'s + * `listNativeComments`, whose doc comment has the full before/after + * reasoning). Mirrors desktop's two-pass `evaluateBinaryRead` + * (`read-binary-access.ts`): + * + * 1. Refuse a well-known secret location OUTRIGHT, even one that happens to + * sit under an otherwise-allowed root (`EditablePathPolicy.isSensitivePath` + * — the SAME denylist this read used to rely on alone, kept as a + * first-pass check, not replaced). + * 2. Otherwise allow only a path that is itself (or a descendant of) one of + * the app's own known project roots, OR one recorded as a tracked + * EXTERNAL artifact / manual include in ANY of those projects' own + * `.youcoded/artifacts.json` sidecars — desktop's own comment on this + * second pass explains why it exists: "a temp-dir xlsx the session + * drawer legitimately shows lives outside every root." + * + * Deliberately excludes live session cwds, unlike `refuseUnknownProjectRoot` + * above (which DOES fold them in via `extraSessionRoots`) — mirrors + * desktop's OWN `knownRoots()` as used INSIDE `authorizeBytesRead` + * specifically (not the broader one `doc-comments-gate.ts`'s + * `refuseUnknownProjectRoot` uses), whose own comment says why: "a phone can + * start a session in any folder, and a by-path read there handed out every + * file in it." `absolutePath` need not be pre-canonicalized — every + * comparison below goes through `realForms` itself, same as + * `refuseUnknownProjectRoot`. + */ +fun allowUntrackedNativeRead(absolutePath: String, homeDir: File, claudeDir: File): Boolean { + val forms = realForms(absolutePath) + if (forms.any { EditablePathPolicy.isSensitivePath(it) }) return false + val roots = knownRoots(homeDir, claudeDir, emptyList()) + if (forms.any { f -> roots.any { r -> f == r || f.startsWith(r + File.separator) } }) return true + for (root in roots) { + val sidecar = when (val r = readSidecar(root)) { + is ReadResult.Ok -> r.sidecar + else -> continue + } + for (a in sidecar.artifacts) { + if (a.kind == "external" && a.absolutePath != null && realForms(a.absolutePath).any { forms.contains(it) }) return true + } + for (inc in sidecar.manualIncludes) { + if (realForms(inc.path).any { forms.contains(it) }) return true + } + } + return false +} diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsMcpNames.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsMcpNames.kt new file mode 100644 index 000000000..38ce90e0a --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsMcpNames.kt @@ -0,0 +1,48 @@ +// Shared vocabulary for the doc-comments Claude Code MCP server on Android — +// the Kotlin half of desktop/src/shared/doc-comments-mcp.ts (T9c/T20, docs/ +// active/specs/2026-09-26-doc-comments-build-design.md §5.3, §9.2). Names two +// Android surfaces that can't import each other must still agree on: the +// deployed server (ClaudeCodeDocCommentsMcp.kt, which writes the byte- +// identical `doc-comments-mcp.js` asset per session) and the permission-auto- +// approve wiring (DocCommentsPermission.kt / ManagedSession.kt) that has to +// recognize a PermissionRequest hook payload for one of these six tools by +// its composed `mcp__{server}__{tool}` name. +// +// WHY the server id is per-deployment random, not this prefix alone +// (adversarial review 2026-09-27, finding #2, ported from the TS file's own +// header): a fixed, public id is guessable by anything that can get its own +// MCP server declared under the same name for the same project. Every real +// server id is `${SERVER_PREFIX}-<8 random hex chars>`, minted fresh per +// session by ClaudeCodeDocCommentsMcp.deploy — this object only builds names +// FROM a caller-supplied id, it never mints one itself. +package com.youcoded.app.doccomments + +object DocCommentsMcpNames { + /** Cosmetic prefix only — matches DOC_COMMENTS_MCP_SERVER_PREFIX in + * desktop/src/shared/doc-comments-mcp.ts. The real per-session id is this + * plus random hex, minted by ClaudeCodeDocCommentsMcp.deploy. */ + const val SERVER_PREFIX = "youcoded-doc-comments" + + private val MUTATOR_BARE_NAMES = listOf( + "ReplyToComment", + "ResolveComment", + "ReopenComment", + "AddComment", + "MoveComment", + ) + + /** Claude Code's own naming convention for an attached MCP tool. */ + fun toolName(serverId: String, bare: String): String = "mcp__${serverId}__$bare" + + /** The one tool always pre-approved via --allowedTools on every target — + * a read never mutates the source file or its comment store. */ + fun readTool(serverId: String): String = toolName(serverId, "ReadFileComments") + + /** The five tools whose target CAN be a Word/Excel file's own bytes + * (§5.2a, decided option 1) — never allow-listed at spawn time, since the + * same tool name also serves a plain-text/markdown/code target that must + * stay frictionless. DocCommentsPermission.kt uses this set (rebuilt + * from the CALLING session's own serverId, never a cross-session value) + * to recognize a PermissionRequest for one of these. */ + fun mutatorTools(serverId: String): List = MUTATOR_BARE_NAMES.map { toolName(serverId, it) } +} diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsPendingQueue.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsPendingQueue.kt new file mode 100644 index 000000000..5c6443f4c --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsPendingQueue.kt @@ -0,0 +1,457 @@ +// The docx/xlsx pending-mutation queue's Android half — T20 of the doc- +// comments build (docs/active/specs/2026-09-26-doc-comments-build-design.md +// §9.2). Mirrors desktop's `pending-mutation-queue.ts` field-for-field, with +// a Kotlin coroutine polling loop standing in for its chokidar watcher (§9.2: +// "not FileObserver... a Kotlin coroutine polling loop inside SessionService" +// — this object is that loop; SessionRegistry/PtyBridge start and stop it). +// +// The byte-identical MCP script (T9c, ClaudeCodeDocCommentsMcp.kt / the +// doc-comments-mcp.js asset) "never touches a .docx/.xlsx file directly" +// (§1.6): for any of the six tools against a Word/Excel target, including a +// plain read, it writes a PendingMutationRequest under +// .youcoded/comments/.pending/.json and polls for a matching +// .result.json. This object is the OTHER end: it notices a new request +// file, applies it through the SAME Kotlin dispatch functions +// (DocCommentsDispatch.kt) the docComments:* bridge already uses — never a +// second, queue-specific copy of "how to write a docx/xlsx comment" — and +// writes the result back. +// +// --- Authorization (mirrors desktop's own three findings, adversarial +// review 2026-09-27) --- +// +// .pending/ is a plain, filesystem-level drop box: anything with ordinary +// write access to the project folder can place a file there. Two independent +// defenses close this, neither alone sufficient — identical reasoning to +// desktop's pending-mutation-queue.ts, restated here because this is an +// independent Kotlin re-implementation, not a port with a shared import: +// +// 1. The applier never trusts a request's own `projectRoot` field. Every +// dispatch call below resolves against `entry.realRoot` — the SAME +// realpathed root `start()` verified via `refuseUnknownProjectRoot` (the +// SAME authority every other doc-comments surface on Android gates on) — +// never `req.projectRoot`, which is advisory-only self-reported data from +// an untrusted file. +// 2. Every request must carry this session's own per-deployment secret +// (`req.token`, compared against every session currently sharing this +// entry's `refs` map via `MessageDigest.isEqual` — the JVM's own constant- +// time byte-array comparison, the same intent as desktop's +// `crypto.timingSafeEqual`) — a value generated fresh at THIS session's +// own spawn time (ClaudeCodeDocCommentsMcp.deploy), which nothing planted +// before the session existed can possibly supply. +// +// A third, purely defense-in-depth check (`isFreshEnough`) refuses to process +// a request file whose own mtime predates this queue's own start — explicitly +// weak alone (a `git clone` commonly stamps a planted file's mtime as "now"), +// never relied on alone; the token check above is the real boundary. +package com.youcoded.app.doccomments + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.json.JSONException +import org.json.JSONObject +import java.io.File +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +object DocCommentsPendingQueue { + /** ~250ms (§9.2's own figure) — comfortably under the MCP script's own + * benchmarked 8s poll bound (claude-code-doc-comments-mcp.ts), while + * still cheap: this only lists one small directory on a timer, never + * touches the target document itself except when a real request lands. */ + private const val POLL_INTERVAL_MS = 250L + + /** Defense-in-depth margin for the pre-existing-file check (see this + * file's own header) — generous on purpose: the token check is the real + * boundary, so this only needs to catch an OBVIOUSLY stale file without + * false-refusing a genuine near-simultaneous write at session start. + * Matches desktop's own FRESHNESS_MARGIN_MS. */ + private const val FRESHNESS_MARGIN_MS = 5000L + + /** How long an orphaned .result.json may sit before a sweep removes it — + * matches desktop's own STALE_RESULT_MS (an hour, comfortably longer + * than any real wait; mirrors cas-write.ts's sweepStaleTmp precedent). */ + private const val STALE_RESULT_MS = 60 * 60 * 1000L + + private class Entry( + val realRoot: String, + val pendingDir: File, + val startedAt: Long, + ) { + /** Claude Code session id -> that session's own request token — + * several sessions can share one project root, each with its own + * independently-generated token (mirrors desktop's Entry.refs). */ + val refs = ConcurrentHashMap() + val inFlight = ConcurrentHashMap.newKeySet() + var job: Job? = null + + @Volatile var stopped = false + } + + /** realpathed project root -> its entry. Guarded by `lock` for start/stop + * mutation; `pollOnce`'s own per-entry work reads an already-published + * Entry and needs no further synchronization (its own maps are + * concurrent). */ + private val entries = HashMap() + private val lock = Any() + + /** WHY the queue owns its scope (2026-09-28 PR review): it used to run in + * the scope of whichever session started it first. Sessions sharing a + * project share ONE queue, so closing that first session cancelled the + * loop while the entry (kept alive by the other session's ref) stayed — + * every Word/Excel comment request from the surviving session then timed + * out until all of them closed. The loop now lives as long as its entry, + * and `stop` cancels it when the last session sharing the project ends. */ + private val queueScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private fun tokensMatch(a: Any?, b: String): Boolean { + if (a !is String || a.length != b.length) return false + return MessageDigest.isEqual(a.toByteArray(Charsets.UTF_8), b.toByteArray(Charsets.UTF_8)) + } + + private fun hasValidToken(entry: Entry, req: JSONObject): Boolean { + val token = req.opt("token") + for (candidate in entry.refs.values) { + if (tokensMatch(token, candidate)) return true + } + return false + } + + /** + * Start (or add a ref to) the pending-mutation queue for `projectRoot` — + * called once per Claude Code session that gets a doc-comments MCP server + * (PtyBridge.start(), via ClaudeCodeDocCommentsMcp.deploy's own + * `Deployment.token`), keyed on `sessionId` so `stop` below can drop + * exactly this session's ref without needing its caller to track a + * refcount itself. + */ + fun start( + sessionId: String, + projectRoot: String, + homeDir: File, + claudeDir: File, + token: String, + ) { + val realRoot = try { + File(projectRoot).canonicalFile.path + } catch (_: Exception) { + return // the project directory vanished before this could start + } + // Never watch the bare OS temp directory as a "project" — mirrors + // desktop's own guard (pending-mutation-queue.ts): several JVM test + // suites use a shared temp root as a stand-in "some real, existing + // directory," and without this guard those tests would leave a real, + // permanent .youcoded/comments/.pending/ under it. + val tmp = try { + File(System.getProperty("java.io.tmpdir") ?: "/tmp").canonicalFile.path + } catch (_: Exception) { + System.getProperty("java.io.tmpdir") ?: "/tmp" + } + if (realRoot == tmp) return + // Reuse the SAME allowlist authority every other doc-comments surface + // on Android gates on (DocCommentsGate.kt), rather than a fourth + // independently-invented check — mirrors desktop's own reuse of + // isKnownRoot() here (adversarial review finding #1) and this + // codebase's own `refuseUnknownProjectRoot(realRoot, ..., [realRoot])` + // precedent (DocCommentsDispatch.kt's own dispatch, whose doc comment + // explains the "a live session's own cwd counts as known too" reason). + if (refuseUnknownProjectRoot(realRoot, homeDir, claudeDir, listOf(realRoot))) return + + synchronized(lock) { + val existing = entries[realRoot] + if (existing != null) { + existing.refs[sessionId] = token + return + } + val pendingDir = File(realRoot, ".youcoded/comments/.pending") + val entry = Entry(realRoot, pendingDir, System.currentTimeMillis()) + entry.refs[sessionId] = token + entries[realRoot] = entry + try { + pendingDir.mkdirs() + } catch (_: Exception) { + // best-effort, mirrors DocCommentsStore.kt's own directory creation + } + entry.job = queueScope.launch { + sweepStaleResults(pendingDir) + while (isActive && !entry.stopped) { + pollOnce(entry, homeDir) + delay(POLL_INTERVAL_MS) + } + } + } + } + + /** Drop `sessionId`'s ref; cancels the polling job once the last session + * sharing this project ends. Called from PtyBridge.stop(). */ + fun stop(sessionId: String, projectRoot: String) { + val realRoot = try { + File(projectRoot).canonicalFile.path + } catch (_: Exception) { + return + } + synchronized(lock) { + val entry = entries[realRoot] ?: return + entry.refs.remove(sessionId) + if (entry.refs.isEmpty()) { + entry.stopped = true + entry.job?.cancel() + entries.remove(realRoot) + } + } + } + + /** Test helper: tear everything down between cases — mirrors desktop's + * own `__resetPendingMutationQueueForTest`. */ + fun resetForTest() { + synchronized(lock) { + for (entry in entries.values) { + entry.stopped = true + entry.job?.cancel() + } + entries.clear() + } + } + + private suspend fun pollOnce(entry: Entry, homeDir: File) { + val files = try { + entry.pendingDir.listFiles { f -> f.isFile && f.name.endsWith(".json") && !f.name.endsWith(".result.json") } + } catch (_: Exception) { + null + } ?: return + for (file in files) { + val id = file.name.removeSuffix(".json") + if (!entry.inFlight.add(id)) continue // a duplicate poll tick for the same file + try { + handleRequestFile(entry, file, homeDir) + } catch (_: Exception) { + // Never let one bad request kill the polling loop for the rest + // of the session — an unexpected exception here (e.g. a + // result-file write failing on a full disk) is dropped the + // same way a malformed request already is; the MCP script's + // own poll times out honestly instead of hanging forever. + } finally { + entry.inFlight.remove(id) + } + } + } + + private suspend fun handleRequestFile(entry: Entry, file: File, homeDir: File) { + // Finding #1's defense-in-depth freshness check — see this file's own + // header for why this is weak ALONE and why the token check is the + // real boundary. Left in place (never deleted, never applied) rather + // than guessed at: a legitimate cold-start race is covered by + // FRESHNESS_MARGIN_MS; anything older is either a planted file or + // genuinely stale litter the sweep below handles. + val mtime = try { + file.lastModified() + } catch (_: Exception) { + 0L + } + if (mtime == 0L || mtime < entry.startedAt - FRESHNESS_MARGIN_MS) return + + // Claim before acting (2026-09-28 PR review, mirrors desktop): renaming + // `.json` to `.claimed` is atomic, so exactly one side wins. If + // the MCP script already withdrew the request after its own timeout + // (it deletes `.json`), this rename fails and nothing is applied — + // the assistant was told "nothing was changed", so a late apply would + // have produced a duplicate comment when it tried again. + val claimed = File(entry.pendingDir, "${file.name.removeSuffix(".json")}.claimed") + if (!file.renameTo(claimed)) return + + val raw = try { + claimed.readText() + } catch (_: Exception) { + claimed.delete() + return + } + val req = try { + JSONObject(raw) + } catch (_: JSONException) { + claimed.delete() + return // malformed request: no trustworthy id to build a result path from + } + val id = req.optString("id", "") + if (id.isEmpty()) { + claimed.delete() + return + } + val resultPath = File(entry.pendingDir, "$id.result.json") + + val result = if (!hasValidToken(entry, req)) { + // Finding #1: the REAL authorization boundary. A mismatched or + // missing token means this file did not come from a session this + // queue is currently serving. + JSONObject().put("ok", false).put("error", "invalid-request-token") + } else { + try { + applyRequest(req, entry.realRoot, homeDir) + } catch (e: Exception) { + JSONObject().put("ok", false).put("error", e.message ?: "apply-failed") + } + } + writeResultAtomic(resultPath, result) + try { + claimed.delete() + } catch (_: Exception) { + // already gone, or a races-with-someone-else's-delete + } + sweepStaleResults(entry.pendingDir) + } + + /** Applies one request through the SAME dispatch functions the + * docComments:* bridge uses — never a second, queue-specific copy of + * "how to write a docx/xlsx comment." `trustedProjectRoot` is ALWAYS + * this queue's own verified `entry.realRoot` — see this file's own + * header, finding #1: `req.projectRoot` (self-reported, unauthenticated) + * is never read here. */ + private suspend fun applyRequest(req: JSONObject, trustedProjectRoot: String, homeDir: File): JSONObject { + val formatName = req.optString("format", "") + val format = when (formatName) { + "docx" -> NativeFormat.DOCX + "xlsx" -> NativeFormat.XLSX + else -> return JSONObject().put("ok", false).put("error", "unknown-format") + } + val path = req.optString("path", "") + val kind = req.optString("kind", "") + + return when (kind) { + "list" -> when (val r = listNativeComments(format, path, trustedProjectRoot, homeDir)) { + is NativeListResult.Ok -> JSONObject().put("ok", true).put( + "comments", + org.json.JSONArray(r.comments.map { it.toJson() }), + ) + is NativeListResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + "add" -> { + val selector = CommentSelector.fromJson(req.optJSONObject("selector")) + ?: return JSONObject().put("ok", false).put("error", "missing-field") + val text = req.optString("text", "") + val author = req.optString("author", "assistant") + val r = when (format) { + NativeFormat.DOCX -> addNativeDocxComment(path, trustedProjectRoot, selector, text, author, homeDir) + NativeFormat.XLSX -> addNativeXlsxComment(path, trustedProjectRoot, selector, text, author, homeDir) + } + when (r) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true).put("id", r.value) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + } + "reply" -> { + val commentId = req.optString("commentId", "") + val text = req.optString("text", "") + val author = req.optString("author", "assistant") + val r = when (format) { + NativeFormat.DOCX -> replyToNativeDocxComment(path, trustedProjectRoot, commentId, text, author, homeDir) + NativeFormat.XLSX -> replyToNativeXlsxComment(path, trustedProjectRoot, commentId, text, author, homeDir) + } + when (r) { + // Mirrors desktop's own "forward `reply` once the writer + // provides one" (design commit 6c612cb9, §1.5/§1.6/§7) — + // unlike desktop, Android's write functions already + // return the real persisted CommentReply unconditionally + // (T17/T19), so this is never a no-op the way the TS + // queue's own optional cast currently is. + is NativeMutateResult.Ok -> JSONObject().put("ok", true).put("reply", r.value.toJson()) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + } + "resolve" -> { + val commentId = req.optString("commentId", "") + val r = when (format) { + NativeFormat.DOCX -> resolveNativeDocxComment(path, trustedProjectRoot, commentId, homeDir) + NativeFormat.XLSX -> resolveNativeXlsxComment(path, trustedProjectRoot, commentId, homeDir) + } + when (r) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + } + "reopen" -> { + val commentId = req.optString("commentId", "") + val r = when (format) { + NativeFormat.DOCX -> reopenNativeDocxComment(path, trustedProjectRoot, commentId, homeDir) + NativeFormat.XLSX -> reopenNativeXlsxComment(path, trustedProjectRoot, commentId, homeDir) + } + when (r) { + is NativeMutateResult.Ok -> JSONObject().put("ok", true) + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + } + "move" -> { + val commentId = req.optString("commentId", "") + val newSelector = CommentSelector.fromJson(req.optJSONObject("newSelector")) + ?: return JSONObject().put("ok", false).put("error", "missing-field") + val r = when (format) { + NativeFormat.DOCX -> moveNativeDocxComment(path, trustedProjectRoot, commentId, newSelector, homeDir) + NativeFormat.XLSX -> moveNativeXlsxComment(path, trustedProjectRoot, commentId, newSelector, homeDir) + } + when (r) { + // Code review 2026-09-27, desktop/Android F1 parity: mirrors + // desktop's own pending-mutation-queue.ts fix (same review, + // same finding) — an xlsx move's FRESH id (its old id's + // embedded-cell hint goes stale the instant it moves) is + // forwarded in the result file so the MCP script's + // MoveComment handler can state it in the tool result text, + // letting a follow-up call skip the full-workbook fallback + // scan. `moveNativeDocxComment` returns `NativeMutateResult + // ` (a docx id never changes on move), so the cast + // below is a no-op there — same shape as + // DocCommentsBridge.kt's own direct-dispatch `move` branch. + is NativeMutateResult.Ok -> { + val result = JSONObject().put("ok", true) + (r.value as? String)?.let { result.put("id", it) } + result + } + is NativeMutateResult.Err -> JSONObject().put("ok", false).put("error", r.error) + } + } + // Adversarial review 2026-09-27, finding #3 (mirrored from + // desktop): an unrecognized or malformed `kind` gets an honest, + // typed refusal — never silently falls through into the `move` + // path with blank commentId/newSelector. + else -> JSONObject().put("ok", false).put("error", "unknown-mutation-kind") + } + } + + /** Writes the result via the SAME mkdir-lock-plus-atomic-rename primitive + * every other write in this feature uses (com.youcoded.app.artifacts. + * mutateFileUnderLock) — no real contention exists on a freshly-and- + * uniquely-named result file, but reusing the shared primitive keeps + * this write fsync'd and torn-write-free the same way every sidecar + * write already is, mirroring desktop's own writeResult. */ + private fun writeResultAtomic(resultPath: File, result: JSONObject) { + com.youcoded.app.artifacts.mutateFileUnderLock(resultPath.absolutePath) { result.toString() } + } + + /** `.result.json` is otherwise only ever deleted by the MCP script's OWN + * successful poll — if that script gave up (its own timeout) or its + * whole process was killed (the session ended) before reading a slow + * request's result, nothing else ever removes the file. Best-effort, + * never blocks or fails request handling — mirrors desktop's own + * sweepStaleResults (finding #4). */ + private fun sweepStaleResults(pendingDir: File) { + val names = try { + pendingDir.list() + } catch (_: Exception) { + null + } ?: return + val now = System.currentTimeMillis() + for (name in names) { + // Also leftover requests (a session killed mid-wait) and claims + // (the app killed mid-apply) — an hour old is never still wanted, + // and they otherwise pile up inside the user's project folder. + if (!name.endsWith(".json") && !name.endsWith(".claimed")) continue + val f = File(pendingDir, name) + try { + if (now - f.lastModified() > STALE_RESULT_MS) f.delete() + } catch (_: Exception) { + // vanished, or races with someone else's delete — nothing to sweep + } + } + } +} diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsPermission.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsPermission.kt new file mode 100644 index 000000000..59b0a3bf5 --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsPermission.kt @@ -0,0 +1,91 @@ +// Android half of desktop's permission-auto-approve.ts `shouldAutoApproveDocComment` +// — §5.2a of the doc-comments build design (decided option 1, Destin "fine w +// A"), T20's own "same permission behaviour in Android's auto-approve path" +// requirement. Ported field-for-field from the TS function; see that file's +// own header for the full reasoning this comment only summarizes. +// +// Native tools (a phone has none of those here — Android's assistant surface +// is Claude Code CLI only) get a real per-call permissionSubject because they +// run through this app's own decidePermission(), which knows the session's +// live permission mode. Claude Code's CLI has no equivalent per-argument +// gate: `--allowedTools` pre-approves a TOOL NAME outright, with no way to +// condition that on an argument value — so a bare allow-list of +// ReplyToComment would ALSO pre-approve it for a Word/Excel target, and not +// allow-listing it makes even a plain-text/markdown reply ask every time, +// contradicting §5.2a's "sparingly... frictionless" intent for the common +// case. +// +// The mechanism (identical to desktop's): ReadFileComments alone is allow- +// listed at spawn time (ClaudeCodeDocCommentsMcp.kt) — a read never mutates +// anything. The five mutation tools are deliberately NOT allow-listed; +// instead, ManagedSession.kt's own PermissionRequest handling (the SAME path +// that already auto-approves a handful of other categories, see that file's +// classifyPermission/shouldAutoApprove) recognizes a call to one of them +// (DocCommentsMcpNames.mutatorTools) and reads its OWN `path` argument +// straight out of the hook payload's tool_input: when nativeFormatFor(path) +// is null (plain-text/markdown/code), it auto-approves unconditionally; +// otherwise only when Claude Code's own live permission mode is one that +// already auto-accepts a native file edit. +package com.youcoded.app.doccomments + +import org.json.JSONObject +import java.io.File +import java.nio.file.Files + +/** + * Modes that behave like Edit/Write already does for a real file write + * (Claude Code auto-accepts a native file edit with no ask) — identical set + * to desktop's FRICTIONLESS_DOC_COMMENT_MODES. `dontAsk`/`auto` are + * deliberately excluded (see that file's own reasoning: neither is documented + * as an unconditional file-edit auto-accept the way `acceptEdits` is). + */ +private val FRICTIONLESS_DOC_COMMENT_MODES = setOf("acceptEdits", "bypassPermissions") + +/** + * Review finding #5 (docs/active/reviews/2026-09-27-doc-comments-t9ab-review.md): + * an ABSOLUTE path that is ITSELF a symlink never gets a free ride through + * either branch below — it falls through to the ordinary ask rather than + * guessing what it disguises. `Files.isSymbolicLink` (not a canonicalizing + * resolve) is the right call here: it inspects ONLY `path` itself, never + * follows anything, mirroring desktop's own `lstatSync(path).isSymbolicLink()`. + * A relative path has no cwd to resolve one against here either, matching + * desktop's own `isAbsolute` short-circuit. + */ +private fun isPathItselfASymlink(path: String): Boolean { + val file = File(path) + if (!file.isAbsolute) return false + return try { + Files.isSymbolicLink(file.toPath()) + } catch (_: Exception) { + false // doesn't exist (a brand-new file) or unreadable — nothing to disguise + } +} + +/** + * `serverId` is THIS session's own randomly-generated doc-comments MCP server + * id (ClaudeCodeDocCommentsMcp.deploy) — never a fixed, guessable string. + * `null` (a session whose deploy failed, or a hook arriving before the + * deployment result is known) fails CLOSED: nothing is matched, and the call + * falls through to the ordinary ask, same as any other unrecognized tool. + * + * `permissionMode` is Claude Code's OWN live permission mode string + * ('default'|'acceptEdits'|'bypassPermissions'|'plan'|'dontAsk'|'auto'), read + * from the SAME PermissionRequest hook payload `toolName`/`toolInput` already + * come from (HookEvent.PermissionRequest.permissionMode) — never this app's + * own `ManagedSession.permissionMode` vocabulary, which uses different + * strings ("normal"/"auto-accept"/"plan"/"bypass"). + */ +fun shouldAutoApproveDocComment( + toolName: String, + toolInput: JSONObject, + permissionMode: String?, + serverId: String?, +): Boolean { + if (serverId == null) return false + if (toolName !in DocCommentsMcpNames.mutatorTools(serverId)) return false + val path = toolInput.opt("path") + if (path !is String) return false + if (isPathItselfASymlink(path)) return false + if (nativeFormatFor(path) == null) return true + return permissionMode != null && FRICTIONLESS_DOC_COMMENT_MODES.contains(permissionMode) +} diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsStore.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsStore.kt new file mode 100644 index 000000000..9e15e4d74 --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsStore.kt @@ -0,0 +1,542 @@ +// Android's half of T1's main-process store — T4 of the doc-comments build +// (docs/active/specs/2026-09-26-doc-comments-build-design.md §1.5, §9.1 +// point 3). Kotlin port of desktop/src/main/doc-comments/doc-comments-store.ts: +// resolving a (path, projectRoot) pair to its one-sidecar-per-file location +// (§1.3/§1.4), refusing anything that resolves outside the project (§1.5's +// realpath-the-FULL-joined-path containment check, corrected in review 2, +// F1 — mirrored here field-for-field, not the weaker root-only shape), +// and every read/mutation against that sidecar. +// +// F1 (T4 implementation review, blocker — supersedes this module's original +// "Android needs no cross-process lock" reasoning, corrected here and in +// docs/active/specs/2026-09-26-doc-comments-build-design.md §1.5/§9.1): the +// original WHY below assumed Android has no second concurrent process +// sharing this file. That is wrong — the Claude Code MCP script (design +// §9.1 point 2, §9.2) runs as a SEPARATE Termux `node` process on Android and +// reads/mutates this exact same sidecar directly, with its own dependency- +// free reimplementation of the mkdir-lock algorithm. A plain in-process +// Mutex does nothing to exclude it. `mutateSidecar` below now goes through +// `com.youcoded.app.artifacts.mutateFileUnderLock` — the SAME cross-process +// mkdir-based lock desktop's `cas-write.ts` uses (same lock path naming, +// same 30s stale-lock timeout) — so the Kotlin app, desktop's main process, +// and the MCP script's own reimplementation all actually exclude each other. +// The in-process Mutex stays in front as a fast, allocation-free path for the +// overwhelmingly common case of zero cross-process contention — it never +// replaces the mkdir lock, only avoids paying its syscall cost when nothing +// outside this JVM is racing the same sidecar. The map key is ALREADY the +// canonicalized `sidecarPath` `locateInProject`/`locateFallback` compute +// (built from `realProjectRoot`, never the caller's unresolved argument), so +// two differently-spelled aliases of the same project (a symlink, a +// `..`-laden path) collapse onto the SAME Mutex AND the same mkdir lock path +// without needing a separate lock-path-canonicalization fix of its own — that +// fix exists on the TS/MCP side only because those derive their lock path +// from the RAW target string; this module never does. +package com.youcoded.app.doccomments + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +/** Mirrors desktop's `Refusal` union (doc-comments-store.ts) plus the extra + * errors T4's dispatch layer adds on top (see DocCommentsDispatch.kt). */ +enum class DocCommentsError(val wire: String) { + PATH_OUTSIDE_PROJECT("path-outside-project"), + COMMENT_NOT_FOUND("comment-not-found"), + SIDECAR_CORRUPT("sidecar-corrupt"), + PATH_NOT_ABSOLUTE("path-not-absolute"), + // F1 (T4 implementation review): the cross-process mkdir lock's own + // timeout — mirrors desktop's doc-comments-store.ts 'lock-timeout' wire + // string exactly, reached when the MCP script (or a stuck prior holder) + // keeps the lock past mutateFileUnderLock's 3s wait. + LOCK_TIMEOUT("lock-timeout"), + // F4 (T5 review): mirrors desktop's own 'invalid-id'/'duplicate-id' — the + // renderer now mints and sends the comment id (see `addComment` below). + INVALID_ID("invalid-id"), + DUPLICATE_ID("duplicate-id"), + // Code review 2026-09-27, Android F3: `mutateSidecar`'s catch used to map + // EVERY `java.io.IOException` escaping `mutateFileUnderLock` to + // SIDECAR_CORRUPT, including one thrown from the WRITE side (`ch.force + // (true)` failing on a full disk, or `Files.move` failing) — nothing to + // do with an unreadable sidecar. This is deliberately Android-only + // (no desktop wire string to mirror by name): desktop's own + // `mutateSidecar` (doc-comments-store.ts) doesn't catch a write-side + // exception AT ALL — it lets it throw uncaught, which the renderer's + // generic top-level catch (`e?.message`) turns into whatever raw OS + // error text Node's fs threw, never a fixed code. Android's bridge + // contract always returns a typed `StoreResult`, so letting an + // exception propagate here isn't an option — this is the accurate + // equivalent given that structural difference, not a literal mirror. + SIDECAR_WRITE_FAILED("sidecar-write-failed"), +} + +sealed class StoreResult { + data class Ok(val value: T) : StoreResult() + data class Err(val error: DocCommentsError) : StoreResult() +} + +private const val MAX_WALKUP_DEPTH = 200 + +/** + * Follows symlinks the whole way down `targetAbs`, walking up to the nearest + * EXISTING ancestor when the leaf (or an intermediate segment) doesn't exist + * yet, re-joining the not-yet-real suffix onto that ancestor's realpath. + * Mirrors doc-comments-store.ts's `realpathWithNonexistentTail` — fails + * closed (returns null) rather than falling through to the raw path on + * ENOENT, exactly the trap review 2 (F1) fixed on the TS side. + */ +private fun realpathWithNonexistentTail(targetAbs: File): File? { + try { + return targetAbs.toPath().toRealPath().toFile() + } catch (_: java.nio.file.NoSuchFileException) { + } catch (_: java.nio.file.NotDirectoryException) { + } catch (_: java.io.IOException) { + return null + } + val segments = mutableListOf() + var dir = targetAbs + for (depth in 0 until MAX_WALKUP_DEPTH) { + val parent = dir.parentFile ?: return null + if (parent.path == dir.path) return null // hit the filesystem root + segments.add(0, dir.name) + try { + val realParent = parent.toPath().toRealPath().toFile() + var result = realParent + for (s in segments) result = File(result, s) + return result + } catch (_: java.nio.file.NoSuchFileException) { + dir = parent + } catch (_: java.nio.file.NotDirectoryException) { + dir = parent + } + } + return null // exceeded the cap — refuse rather than keep walking +} + +/** Realpaths the FULL joined path (via the walk-up above) and tests it + * against the realpathed root — write-authorization.ts's `judgeRelativeRecord()` + * shape, not git-service.ts's shallower root-only one (review 2, F1). Returns + * the resolved, verified-contained path on success. */ +private fun checkContainment(realProjectRoot: String, abs: File): File? { + val realAbs = realpathWithNonexistentTail(abs) ?: return null + val withSep = if (realProjectRoot.endsWith(File.separator)) realProjectRoot else realProjectRoot + File.separator + return if (realAbs.path == realProjectRoot || realAbs.path.startsWith(withSep)) realAbs else null +} + +/** Mirrors `path.resolve(root, filePath)`: an absolute `filePath` ignores + * `root` entirely (Java's `File(parent, child)` does the same when `child` + * is itself absolute — confirmed against `java.io.File`'s own javadoc). */ +private fun resolveAgainst(root: String, filePath: String): File { + val f = File(filePath) + return if (f.isAbsolute) f else File(root, filePath) +} + +data class Located(val sidecarPath: String, val realProjectRoot: String, val sourceAbsolutePath: String) + +private val SIDECAR_DIR = listOf(".youcoded", "comments") +private val FALLBACK_DIR = listOf(".youcoded", "loose-file-comments") + +private fun sidecarDirFor(realProjectRoot: String): File { + var dir = File(realProjectRoot) + for (seg in SIDECAR_DIR) dir = File(dir, seg) + return dir +} + +/** + * §1.3's sidecar location for a file inside a known project — refused when + * `filePath` resolves outside `projectRoot`, including via an absolute-path- + * shaped argument or a symlink. `sidecarPath` is built from the RESOLVED, + * containment-verified target (never the caller's unresolved `filePath`) so a + * path that escapes the project and comes back in through an outside-rooted + * symlink can never leave stray `..` segments in the relative suffix — same + * reasoning as doc-comments-store.ts's own `locateInProject` doc comment. + */ +private fun locateInProject(projectRoot: String, filePath: String): StoreResult { + val realProjectRoot = try { + File(projectRoot).toPath().toRealPath().toFile().path + } catch (_: Exception) { + return StoreResult.Err(DocCommentsError.PATH_OUTSIDE_PROJECT) + } + val abs = resolveAgainst(realProjectRoot, filePath) + val realAbsFile = checkContainment(realProjectRoot, abs) ?: return StoreResult.Err(DocCommentsError.PATH_OUTSIDE_PROJECT) + val realAbs = realAbsFile.path + val rel = File(realProjectRoot).toPath().relativize(File(realAbs).toPath()).toString() + val sidecarPath = File(sidecarDirFor(realProjectRoot), "$rel.json").path + return StoreResult.Ok(Located(sidecarPath, realProjectRoot, realAbs)) +} + +/** + * §1.4's fallback for a file with no known project root: a global, per- + * machine store keyed by a hash of the file's own resolved absolute path. + * Mirrors desktop's `locateFallback` — no containment check applies (there is + * no root to escape), but a non-absolute `path` is refused rather than + * silently resolved against this process's own cwd (F4 on the TS side). + */ +private fun locateFallback(absoluteFilePath: String, homeDir: File): StoreResult { + if (!File(absoluteFilePath).isAbsolute) return StoreResult.Err(DocCommentsError.PATH_NOT_ABSOLUTE) + val abs = File(absoluteFilePath).absoluteFile + val resolved = realpathWithNonexistentTail(abs) ?: abs + val hash = sha256Hex(resolved.path) + var dir = homeDir + for (seg in FALLBACK_DIR) dir = File(dir, seg) + return StoreResult.Ok(File(dir, "$hash.json").path) +} + +// `internal` (not `private`) — T17's docx write pipeline (DocxComments.kt) +// reuses this to derive the SAME kind of stable, per-absolute-path hash for +// its own `~/.claude/youcoded-doc-backups/.docx.bak` rolling backup +// path (mirroring desktop's write-pipeline.ts `backupPathFor`), rather than a +// second, independently-written hashing helper that could drift from this +// one's algorithm. +internal fun sha256Hex(s: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(s.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } +} + +private fun resolveSidecarPath(path: String, projectRoot: String?, homeDir: File): StoreResult { + if (projectRoot != null) { + return when (val located = locateInProject(projectRoot, path)) { + is StoreResult.Ok -> StoreResult.Ok(located.value.sidecarPath) + is StoreResult.Err -> located + } + } + return locateFallback(path, homeDir) +} + +/** §3.2/§4.1 dispatch's own resolution target: the SOURCE file's containment- + * verified absolute path (never a sidecar — a `.docx`/`.xlsx` has none, per + * §1.1). Mirrors desktop's `resolveSourceFilePath`. */ +fun resolveSourceFilePath(path: String, projectRoot: String?, homeDir: File): StoreResult { + if (projectRoot != null) { + return when (val located = locateInProject(projectRoot, path)) { + is StoreResult.Ok -> StoreResult.Ok(located.value.sourceAbsolutePath) + is StoreResult.Err -> located + } + } + if (!File(path).isAbsolute) return StoreResult.Err(DocCommentsError.PATH_NOT_ABSOLUTE) + val abs = File(path).absoluteFile + val resolved = realpathWithNonexistentTail(abs) ?: abs + return StoreResult.Ok(resolved.path) +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +/** A missing sidecar is the overwhelmingly common case (a file with zero + * comments) — returns an empty list, never an error, same as desktop. */ +fun listComments(path: String, projectRoot: String?, homeDir: File): StoreResult> { + val resolved = resolveSidecarPath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return resolved + val sidecarPath = (resolved as StoreResult.Ok).value + val file = File(sidecarPath) + // A missing sidecar (ENOENT-equivalent) is the common "zero comments" + // case; an EXISTING-but-unreadable one is refused instead of silently + // reported as empty, mirroring desktop's own listComments (which + // re-throws for anything other than ENOENT rather than swallowing it). + if (!file.exists()) return StoreResult.Ok(emptyList()) + val onDisk = try { file.readText(Charsets.UTF_8) } catch (_: java.io.IOException) { + return StoreResult.Err(DocCommentsError.SIDECAR_CORRUPT) + } + val parsed = CommentsSidecarFile.parse(onDisk) ?: return StoreResult.Err(DocCommentsError.SIDECAR_CORRUPT) + return StoreResult.Ok(parsed.comments) +} + +// --------------------------------------------------------------------------- +// Mutations — every one goes through mutateSidecar (an in-process Mutex fast +// path in front of the real cross-process mkdir lock, F1), never a bare +// read-then-write. +// --------------------------------------------------------------------------- + +private val locks = ConcurrentHashMap() +private fun lockFor(sidecarPath: String): Mutex = locks.computeIfAbsent(sidecarPath) { Mutex() } + +private sealed class Apply { + data class Applied(val file: CommentsSidecarFile, val extra: T) : Apply() + object NotFound : Apply() + // F4 (T5 review): a distinct outright refusal from inside the lock (e.g. + // `addComment`'s own duplicate-id check below) — mirrors desktop's own + // `mutateSidecar` accepting a `Refusal` alongside `'not-found'`. + data class Refused(val error: DocCommentsError) : Apply() +} + +/** + * F1 (T4 implementation review, blocker): read-modify-write the sidecar + * through `com.youcoded.app.artifacts.mutateFileUnderLock` — the Kotlin port + * of desktop's cas-write.ts cross-process mkdir lock (same lock path naming, + * same 30s stale-lock timeout) — INSIDE the in-process Mutex fast path. The + * Mutex alone used to be the whole story; it still runs first (free once + * acquired, no syscall), but the mkdir lock underneath it is what actually + * excludes the Claude Code MCP script's separate Termux process (design + * §9.1/§9.2) from writing the SAME sidecar concurrently. + */ +private suspend fun mutateSidecar(sidecarPath: String, apply: (CommentsSidecarFile) -> Apply): StoreResult { + return lockFor(sidecarPath).withLock { + var outcome: StoreResult? = null + // Code review 2026-09-27, Android F3: `mutateFileUnderLock`'s own + // callback (below) only ever RUNS once its internal read has already + // succeeded (CasWrite.kt's `mutateFileUnderLock` reads `onDisk` + // BEFORE invoking this lambda, rethrowing any non-ENOENT read + // IOException before we ever get here). So an IOException caught + // below, if `enteredCallback` is already true, can only have come + // from the WRITE side (`ch.force(true)` / `Files.move`, both AFTER + // this lambda returns) — never from an unreadable existing sidecar. + var enteredCallback = false + val acquired = try { + com.youcoded.app.artifacts.mutateFileUnderLock(sidecarPath) { onDisk -> + enteredCallback = true + // WHY a read failure on an EXISTING file refuses rather than + // falling back to an empty sidecar: silently treating "exists + // but unreadable" the same as "doesn't exist yet" would let + // this mutation WRITE a fresh, empty-plus-one-comment file + // over whatever couldn't be read — discarding history a + // corrupt-but-present file might still hold. Only a + // genuinely absent file (onDisk == null) gets the + // empty-sidecar default, mirroring desktop's own + // `mutateSidecar` (doc-comments-store.ts). + val current = if (onDisk == null) { + CommentsSidecarFile.empty() + } else { + CommentsSidecarFile.parse(onDisk) ?: run { + outcome = StoreResult.Err(DocCommentsError.SIDECAR_CORRUPT) + return@mutateFileUnderLock null + } + } + when (val applied = apply(current)) { + is Apply.NotFound -> { + outcome = StoreResult.Err(DocCommentsError.COMMENT_NOT_FOUND) + null + } + is Apply.Refused -> { + outcome = StoreResult.Err(applied.error) + null + } + is Apply.Applied -> { + outcome = StoreResult.Ok(applied.extra) + applied.file.toJson().toString() + } + } + } + } catch (_: java.io.IOException) { + if (enteredCallback) { + // The read (and `apply`) already succeeded — this can only be + // a WRITE-side failure (disk full, a failed atomic rename). + // Reporting it as "sidecar-corrupt" would send someone + // chasing file corruption that isn't there + // (error-message-standards.md: never invent an error cause). + return@withLock StoreResult.Err(DocCommentsError.SIDECAR_WRITE_FAILED) + } + // An EXISTING-but-unreadable sidecar (permissions, a torn read) — + // refuse here rather than let mutateFileUnderLock's own read + // exception propagate uncaught; same outcome the pre-F1 + // in-process-only read path used to produce directly. + return@withLock StoreResult.Err(DocCommentsError.SIDECAR_CORRUPT) + } + if (!acquired) return@withLock StoreResult.Err(DocCommentsError.LOCK_TIMEOUT) + outcome ?: throw IllegalStateException( + "DocCommentsStore.mutateSidecar: mutateFileUnderLock resolved without invoking its mutate callback", + ) + } +} + +private fun findComment(file: CommentsSidecarFile, id: String): PersistedComment? = file.comments.find { it.id == id } + +// F2 (T4 implementation review, major): carries `file.raw` forward so a +// top-level unknown field on the sidecar's own JSON object (beside +// `version`/`comments`) survives every mutation, mirroring desktop's own +// `{ ...file, comments: [...] }` spread. +private fun replaceComment(file: CommentsSidecarFile, id: String, next: PersistedComment): CommentsSidecarFile = + CommentsSidecarFile(file.version, file.comments.map { if (it.id == id) next else it }, file.raw) + +// F4 (T5 review): a caller-supplied id must look like this store's own +// `c-${UUID}` shape — mirrors desktop's own COMMENT_ID_RE. +private val COMMENT_ID_RE = Regex( + "^c-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\$", + RegexOption.IGNORE_CASE, +) + +/** Account-ready id (§1.2): a UUID, never a counter. F4 (T5 review): `id` is + * the RENDERER-minted id (the shared React UI's own `addComment`) — used + * instead of minting a fresh one here so the renderer's optimistic local id + * and the persisted id are the same string from the start. Optional-then- + * required (this pass keeps it optional): a caller that hasn't been updated + * yet still gets a server-minted id exactly as before. */ +suspend fun addComment( + path: String, + projectRoot: String?, + selector: CommentSelector, + text: String, + author: CommentAuthor, + homeDir: File, + id: String? = null, +): StoreResult { + val resolved = resolveSidecarPath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return resolved + val sidecarPath = (resolved as StoreResult.Ok).value + if (id != null && !COMMENT_ID_RE.matches(id)) { + return StoreResult.Err(DocCommentsError.INVALID_ID) + } + val realId = id ?: ("c-" + java.util.UUID.randomUUID().toString()) + val comment = PersistedComment( + id = realId, path = path, selector = selector, text = text, author = author, + createdAt = System.currentTimeMillis(), replies = emptyList(), resolved = false, history = emptyList(), + ) + return mutateSidecar(sidecarPath) { file -> + // F4: refuse a caller-supplied id colliding with one already in this + // sidecar, checked against the CURRENT on-disk state inside the lock — + // mirrors desktop's own addComment. + if (id != null && findComment(file, id) != null) { + Apply.Refused(DocCommentsError.DUPLICATE_ID) + } else { + // F2: preserve file.raw here too (a brand-new sidecar has none). + Apply.Applied(CommentsSidecarFile(file.version, file.comments + comment, file.raw), realId) + } + } +} + +/** T5 review parity (design §1.6, F2): returns the real persisted + * `CommentReply` — the SAME enrichment desktop's own plain-sidecar + * `replyToComment` (doc-comments-store.ts) now returns, uniformly across + * every target type (§1.6's table entry is general, not gated to native + * formats), keeping desktop/remote/Android response shapes in parity. */ +suspend fun replyToComment( + path: String, + projectRoot: String?, + id: String, + text: String, + author: CommentAuthor, + homeDir: File, +): StoreResult { + val resolved = resolveSidecarPath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return resolved + val sidecarPath = (resolved as StoreResult.Ok).value + return mutateSidecar(sidecarPath) { file -> + val comment = findComment(file, id) ?: return@mutateSidecar Apply.NotFound + val replyId = nextReplyId(comment) + val reply = CommentReply(replyId, author, text, System.currentTimeMillis()) + val next = comment.copy(replies = comment.replies + reply) + Apply.Applied(replaceComment(file, id, next), reply) + } +} + +suspend fun resolveComment(path: String, projectRoot: String?, id: String, by: CommentAuthor, homeDir: File): StoreResult { + val resolved = resolveSidecarPath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return resolved + val sidecarPath = (resolved as StoreResult.Ok).value + return mutateSidecar(sidecarPath) { file -> + val comment = findComment(file, id) ?: return@mutateSidecar Apply.NotFound + val next = comment.copy( + resolved = true, + // Full audit trail (§1.1) — not just the latest state. + history = comment.history + ResolveEvent(by, System.currentTimeMillis(), "resolved"), + ) + Apply.Applied(replaceComment(file, id, next), Unit) + } +} + +suspend fun reopenComment(path: String, projectRoot: String?, id: String, by: CommentAuthor, homeDir: File): StoreResult { + val resolved = resolveSidecarPath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return resolved + val sidecarPath = (resolved as StoreResult.Ok).value + return mutateSidecar(sidecarPath) { file -> + val comment = findComment(file, id) ?: return@mutateSidecar Apply.NotFound + val next = comment.copy( + resolved = false, + history = comment.history + ResolveEvent(by, System.currentTimeMillis(), "reopened"), + ) + Apply.Applied(replaceComment(file, id, next), Unit) + } +} + +/** §2/§5's re-anchor tool target: replace a comment's selector wholesale. + * This store trusts whatever selector it's given — deciding whether/where a + * selector resolves is the anchoring pass's job (§2), which runs in the + * WebView over the shared doc-comments-anchor.ts, not this Kotlin module. */ +suspend fun moveComment(path: String, projectRoot: String?, id: String, newSelector: CommentSelector, homeDir: File): StoreResult { + val resolved = resolveSidecarPath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return resolved + val sidecarPath = (resolved as StoreResult.Ok).value + return mutateSidecar(sidecarPath) { file -> + val comment = findComment(file, id) ?: return@mutateSidecar Apply.NotFound + Apply.Applied(replaceComment(file, id, comment.copy(selector = newSelector)), Unit) + } +} + +/** Edit/delete build (2026-09-28, design doc §"Edit and delete"): mirrors + * desktop's own `editComment`/`deleteComment`/`editReply`/`deleteReply` + * (doc-comments-store.ts) field-for-field — anyone's comment/reply can be + * edited or deleted (no author check: decisions.json, + * `doc-comments.edit-delete.questions.answers.json`, is explicit: "anyone's + * comment/reply can be edited or deleted"). No "edited" marker is stored or + * shown, so this is a plain text overwrite — never a history-append the way + * resolve/reopen's audit trail works. */ +suspend fun editComment(path: String, projectRoot: String?, id: String, text: String, homeDir: File): StoreResult { + val resolved = resolveSidecarPath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return resolved + val sidecarPath = (resolved as StoreResult.Ok).value + return mutateSidecar(sidecarPath) { file -> + val comment = findComment(file, id) ?: return@mutateSidecar Apply.NotFound + Apply.Applied(replaceComment(file, id, comment.copy(text = text)), Unit) + } +} + +/** Deleting a THREAD's first comment deletes the whole thread (decision doc: + * "deleting a thread's first comment deletes the whole thread") — this + * channel always removes the whole `PersistedComment` row, replies + * included; a single reply is removed by `deleteReply` below instead. */ +suspend fun deleteComment(path: String, projectRoot: String?, id: String, homeDir: File): StoreResult { + val resolved = resolveSidecarPath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return resolved + val sidecarPath = (resolved as StoreResult.Ok).value + return mutateSidecar(sidecarPath) { file -> + findComment(file, id) ?: return@mutateSidecar Apply.NotFound + Apply.Applied(CommentsSidecarFile(file.version, file.comments.filter { it.id != id }, file.raw), Unit) + } +} + +/** Mirrors `replyToComment`'s own enrichment (§1.6, T5 review F2): returns + * the edited reply so a caller's optimistic edit can be confirmed/corrected + * in place, the same wire shape every reply-shaped mutation here already + * uses. */ +suspend fun editReply(path: String, projectRoot: String?, id: String, replyId: String, text: String, homeDir: File): StoreResult { + val resolved = resolveSidecarPath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return resolved + val sidecarPath = (resolved as StoreResult.Ok).value + return mutateSidecar(sidecarPath) { file -> + val comment = findComment(file, id) ?: return@mutateSidecar Apply.NotFound + val idx = comment.replies.indexOfFirst { it.id == replyId } + if (idx == -1) return@mutateSidecar Apply.NotFound + val reply = comment.replies[idx].copy(text = text) + val replies = comment.replies.toMutableList() + replies[idx] = reply + Apply.Applied(replaceComment(file, id, comment.copy(replies = replies)), reply) + } +} + +suspend fun deleteReply(path: String, projectRoot: String?, id: String, replyId: String, homeDir: File): StoreResult { + val resolved = resolveSidecarPath(path, projectRoot, homeDir) + if (resolved is StoreResult.Err) return resolved + val sidecarPath = (resolved as StoreResult.Ok).value + return mutateSidecar(sidecarPath) { file -> + val comment = findComment(file, id) ?: return@mutateSidecar Apply.NotFound + if (comment.replies.none { it.id == replyId }) return@mutateSidecar Apply.NotFound + val next = comment.copy(replies = comment.replies.filter { it.id != replyId }) + Apply.Applied(replaceComment(file, id, next), Unit) + } +} + +/** WHY the highest existing number + 1, never the reply count + 1 + * (2026-09-28 PR review, mirrors doc-comments-store.ts's `nextReplyId`): + * after a middle reply was deleted the count minted an id that was still in + * use, so edits and deletes hit the wrong reply. */ +internal fun nextReplyId(comment: PersistedComment): String { + val prefix = "${comment.id}-r" + var max = 0 + for (r in comment.replies) { + if (!r.id.startsWith(prefix)) continue + val n = r.id.substring(prefix.length).toIntOrNull() ?: continue + if (n > max) max = n + } + return "$prefix${maxOf(max, comment.replies.size) + 1}" +} diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsXmlSafety.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsXmlSafety.kt new file mode 100644 index 000000000..baa80bdc2 --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsXmlSafety.kt @@ -0,0 +1,73 @@ +// Android port of the strict namespace check in desktop's +// xml-text-safety.ts (`undeclaredPrefixes`/`introducesUndeclaredPrefix`) and +// write-pipeline.ts (`xmlPartsStayWellFormed`). +// +// WHY (2026-09-28 PR review): both write modules parse with namespace +// awareness OFF, so the DOM happily serializes an element or attribute whose +// prefix was never declared (`` in a comments.xml with no +// `xmlns:w14`). That is not well-formed XML — Word and Excel refuse or +// "repair" the file — and the verify step re-reads with the same lenient +// parser, so it could not notice. This is the missing strict check, run by +// both pipelines before a new file replaces the original. +package com.youcoded.app.doccomments + +import java.io.File +import java.util.zip.ZipFile + +// Only markup is scanned (text between tags is skipped), so comment text such +// as "ratio:1=2" can never look like an attribute. +private val TAG_RE = Regex("<[^!?][^>]*>") +private val TAG_NAME_PREFIX_RE = Regex("^ { + val declared = mutableSetOf("xml", "xmlns") + val used = mutableSetOf() + for (tag in TAG_RE.findAll(xml)) { + val t = tag.value + DECLARED_PREFIX_RE.findAll(t).forEach { declared.add(it.groupValues[1]) } + TAG_NAME_PREFIX_RE.find(t)?.let { used.add(it.groupValues[1]) } + ATTR_PREFIX_RE.findAll(t).forEach { used.add(it.groupValues[1]) } + } + return used - declared +} + +/** True when `after` leaves a prefix undeclared that `before` (the same part + * before this write, or `null` for a new part) did not. */ +internal fun introducesUndeclaredPrefix(before: String?, after: String): Boolean { + val already = if (before == null) emptySet() else undeclaredPrefixes(before) + return undeclaredPrefixes(after).any { it !in already } +} + +/** Every XML part `outFile` changed relative to `originalFile` (judged by + * checksum and size, so untouched parts are never read) must not newly use + * an undeclared prefix. Unreadable archives return `true`: each format's own + * verify already refuses those. */ +internal fun xmlPartsStayWellFormed(outFile: File, originalFile: File): Boolean { + return try { + ZipFile(outFile).use { after -> + val before = try { ZipFile(originalFile) } catch (_: Exception) { null } + try { + val entries = after.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (entry.isDirectory || !XML_PART_RE.containsMatchIn(entry.name)) continue + val original = before?.getEntry(entry.name) + if (original != null && original.crc == entry.crc && original.size == entry.size) continue + val afterXml = readEntryBounded(after, entry).toString(Charsets.UTF_8) + val beforeXml = original?.let { readEntryBounded(before, it).toString(Charsets.UTF_8) } + if (introducesUndeclaredPrefix(beforeXml, afterXml)) return false + } + true + } finally { + before?.close() + } + } + } catch (_: java.util.zip.ZipException) { + true + } +} diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsZipSizeGuard.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsZipSizeGuard.kt new file mode 100644 index 000000000..1c3599706 --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocCommentsZipSizeGuard.kt @@ -0,0 +1,174 @@ +// Android port of desktop/src/main/doc-comments/zip-size-guard.ts — the +// decompression-bomb guard T10's own implementation review added (F2 — +// major). §3.2a/§4.3a's own "documented, watched file-size guard for +// Android's load-whole-archive approach" instruction names this file's +// desktop counterpart directly; this is that guard's Kotlin equivalent. +// +// WHY the check is possible cheaply, and BEFORE any entry is decompressed: +// `java.util.zip.ZipFile` parses only the archive's LOCAL and CENTRAL +// DIRECTORY headers when opened — `ZipEntry.getSize()` (the entry's declared +// UNCOMPRESSED size) comes straight from that metadata and is available +// immediately, with no call to `getInputStream()` on that entry (which is +// what actually triggers decompression). This is the exact same trust +// relationship desktop's own comment documents for JSZip's +// `_data.uncompressedSize` — a crafted archive can declare an enormous +// uncompressed size for an entry whose actual compressed bytes are tiny (a +// "zip bomb"), and decompressing it blind would try to allocate however many +// bytes the archive CLAIMS, which is exactly the unbounded-cost class +// `.claude/rules/performance.md` rule 1 (ported to Android via +// `docs/android-runtime.md`'s own main-thread-never-blocks framing) exists to +// keep out. +package com.youcoded.app.doccomments + +import java.util.zip.ZipEntry +import java.util.zip.ZipFile + +/** Same ceiling as desktop's `MAX_DECLARED_UNCOMPRESSED_BYTES` — a real Word + * comments part or document body is kilobytes even for a large document; + * 200MB is generously above any legitimate file this feature will ever see. + * A task-time starting number, not a benchmarked one (§4.3a's own framing) — + * lower it if a real-world report calls for it. Kept in sync with desktop's + * own constant by convention, not by a shared build artifact (the two + * runtimes have no shared code path to enforce this at compile time). */ +private const val MAX_DECLARED_UNCOMPRESSED_BYTES: Long = 200L * 1024 * 1024 + +sealed class ZipSizeGuardResult { + object Ok : ZipSizeGuardResult() + /** Mirrors desktop's `'archive-too-large'` error. */ + object ArchiveTooLarge : ZipSizeGuardResult() +} + +/** + * Refuses if any NAMED entry (the specific parts a caller is about to + * decompress) declares an uncompressed size over the ceiling. A missing + * entry is skipped — an absent part (e.g. no `commentsExtended.xml`) is a + * normal shape (§3.2), not a size concern. An entry whose declared size is + * unknown (`ZipEntry.getSize() == -1`, possible for a STREAMED zip written + * without a size in its local header — `ZipFile` normally has it from the + * central directory, but this is defensive) is treated as within-ceiling + * rather than refused, matching desktop's own `declaredUncompressedSize` + * falling back to `0` when the internal field is absent. + */ +fun checkNamedEntriesWithinCeiling(zip: ZipFile, names: List): ZipSizeGuardResult { + for (name in names) { + val entry: ZipEntry = zip.getEntry(name) ?: continue + val size = entry.size + if (size > MAX_DECLARED_UNCOMPRESSED_BYTES) { + return ZipSizeGuardResult.ArchiveTooLarge + } + } + return ZipSizeGuardResult.Ok +} + +/** + * Refuses if the SUM of every entry's declared uncompressed size (or any + * single entry alone) is over the ceiling. Mirrors desktop's + * `checkTotalWithinCeiling` (zip-size-guard.ts) — used by `XlsxComments.kt` + * (T18/§4.3a), which — unlike Word's fixed three-named-part read + * (`checkNamedEntriesWithinCeiling`, above) — has no small fixed set of part + * names to check ahead of time: an xlsx's worksheet/comments part names and + * count vary per workbook (§4.3a's own "sheetN.xml"/"commentsN.xml" + * numbering follows worksheet position, not a fixed count), so the whole + * archive is pre-scanned before ANY part is parsed. An entry with an unknown + * declared size (`ZipEntry.getSize() == -1`) contributes `0` to the running + * total rather than being refused outright, matching desktop's own + * `declaredUncompressedSize` fallback. + */ +fun checkAllEntriesWithinCeiling(zip: ZipFile): ZipSizeGuardResult { + var total = 0L + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (entry.isDirectory) continue + val size = if (entry.size >= 0) entry.size else 0L + if (size > MAX_DECLARED_UNCOMPRESSED_BYTES) return ZipSizeGuardResult.ArchiveTooLarge + total += size + if (total > MAX_DECLARED_UNCOMPRESSED_BYTES) return ZipSizeGuardResult.ArchiveTooLarge + } + return ZipSizeGuardResult.Ok +} + +// ============================================================================= +// F3 (implementation review, both docx/xlsx read paths): a decompression-time +// backstop UNDERNEATH the two declared-size checks above. +// +// WHY the checks above are not enough on their own: `checkNamedEntriesWithinCeiling`/ +// `checkAllEntriesWithinCeiling` only ever read `ZipEntry.getSize()` — a value +// that comes straight from the archive's own CENTRAL DIRECTORY metadata and is +// never verified against what the entry ACTUALLY decompresses to. A crafted +// entry can declare a small (or, per the `-1` fallback above, effectively +// zero-contributing) uncompressed size while its real DEFLATE stream expands +// far past it — the declared-size checks would wave it straight through, and +// the two readers would then decompress the FULL real size into memory +// regardless. `readEntryBounded` below is the check that doesn't trust +// anything the archive DECLARES: it counts REAL bytes as they come off the +// decompression stream and aborts the moment the running total crosses the +// ceiling, so at most one chunk's worth of bytes beyond the ceiling is ever +// held in memory — never the full oversized output. +// ============================================================================= + +/** Thrown by `readEntryBounded` when an entry's REAL decompressed byte count + * exceeds `ceilingBytes` — a distinct signal from `ZipSizeGuardResult + * .ArchiveTooLarge` above (which is a plain return value, not an exception) + * only because `readEntryBounded` is called from deep inside each reader's + * own parsing pipeline, several stack frames below where the equivalent + * `DocxReadResult.Err`/`XlsxReadResult.Err` is actually constructed and + * returned — an exception lets it unwind cleanly back to that one place + * (each reader's own top-level `try`/`catch`) without every intermediate + * call needing its own sealed-result plumbing for a case that should never + * happen against a legitimate file. */ +class ZipBombDetectedException : Exception() + +/** Read-loop chunk size for `readEntryBounded` — large enough to keep the + * per-chunk overhead low, small enough that a refusal fires promptly once + * the running total crosses the ceiling rather than after one more huge + * buffer's worth of needless decompression. */ +private const val BOUNDED_READ_CHUNK_BYTES = 8192 + +/** + * Reads `entry`'s decompressed bytes from `zip`, counting REAL bytes as they + * come off the DEFLATE stream and throwing `ZipBombDetectedException` the + * MOMENT the running total exceeds `ceilingBytes` — see this section's own + * header for why this exists as a backstop UNDERNEATH + * `checkNamedEntriesWithinCeiling`/`checkAllEntriesWithinCeiling`, which only + * ever look at the archive's own declared metadata. + * + * `ceilingBytes` defaults to the same `MAX_DECLARED_UNCOMPRESSED_BYTES` + * ceiling the declared-size checks use above. A caller only ever overrides it + * in a test — proving the abort fires correctly, well under the real 200MB + * production ceiling, without a test having to actually allocate/inflate + * anywhere near that much data itself. + */ +/** The most one part may decompress to IN MEMORY on a phone. + * + * WHY lower than the 200MB archive ceiling (2026-09-28 PR review): this + * reader holds the whole part in RAM (and a growing buffer briefly needs + * about twice that), then parses it into a DOM several times larger again. + * A phone app's heap is typically 256–512MB, so a part near 200MB crashed + * the app with OutOfMemoryError before the 200MB refusal could fire — the + * exact crash a hostile or merely huge file must not cause. The test that + * proves the refusal hit that OutOfMemoryError on GitHub's test machines. + * 32MB is still several times the largest real Word/Excel part this feature + * touches (a 500-page Word body is ~5–10MB). Above it Android refuses with + * the same honest "too large" error desktop gives past its own ceiling; + * desktop's Node process keeps the higher limit. */ +internal const val MAX_IN_MEMORY_PART_BYTES: Long = 32L * 1024 * 1024 + +fun readEntryBounded(zip: ZipFile, entry: ZipEntry, ceilingBytes: Long = MAX_IN_MEMORY_PART_BYTES): ByteArray { + zip.getInputStream(entry).use { input -> + // Pre-size from the declared size (never trusted beyond the ceiling) + // so an honest large part doesn't pay for repeated buffer doubling. + val sizeHint = entry.size.coerceIn(0L, ceilingBytes).toInt() + val out = java.io.ByteArrayOutputStream(maxOf(sizeHint, BOUNDED_READ_CHUNK_BYTES)) + val chunk = ByteArray(BOUNDED_READ_CHUNK_BYTES) + var total = 0L + while (true) { + val n = input.read(chunk) + if (n == -1) break + total += n + if (total > ceilingBytes) throw ZipBombDetectedException() + out.write(chunk, 0, n) + } + return out.toByteArray() + } +} diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/DocxComments.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/DocxComments.kt new file mode 100644 index 000000000..493640d6e --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/DocxComments.kt @@ -0,0 +1,2230 @@ +// Word (.docx) comment READING on Android — T16 of the doc-comments build +// (docs/active/specs/2026-09-26-doc-comments-build-design.md §3.2a, §8 T16). +// Kotlin port of desktop/src/main/doc-comments/docx-comments.ts's own +// `readDocxComments` (T10) — read-only; add/reply/resolve/reopen/move are +// T17's scope, not this file's. +// +// WHY this exists as real Kotlin rather than a WebView/JS reuse (§3.2a, +// mirroring §3.2's own F1 reasoning for desktop): Android's assistant path +// (the MCP script, §9) and a backgrounded PTY session must be able to read a +// Word comment with no WebView attached or foregrounded at all — routing a +// same-process Kotlin call out to a WebView instance that might not exist +// right now and back is not workable, the same conclusion §3.2's F1 reached +// for the desktop renderer. +// +// WHY `java.util.zip` + `javax.xml.parsers` need no new Gradle dependency +// (§3.2a): both are part of the JDK class library every Android device +// ships — `ZipFile` for random-access named-entry reads (`comments.xml`, +// `commentsExtended.xml`, `document.xml` — the .docx is loaded from its own +// file on disk, never as an in-memory byte array over IPC, since this Kotlin +// code already has direct filesystem access, unlike a renderer that needs +// `artifacts:read-binary` to get bytes at all), and +// `DocumentBuilderFactory`/`DocumentBuilder` for XML, producing the same +// `org.w3c.dom.Document` shape desktop's `@xmldom/xmldom`-compatible +// `linkedom` gives its own algorithm (§3.2a's own note: "the same DOM shape +// ... barely changes shape from an originally renderer-shaped design"). +// +// SECURITY: the design (task prompt for T16) requires a secure XML parser +// configuration — no DTDs, no external entities — since `path` (and by +// extension the bytes this reads) can originate from model-controlled input +// at a native-tool or MCP surface (§1.5's own "a new tool surface must +// implement its own containment check, it inherits none" reasoning applies +// equally to what a parser will fetch/expand on a malicious document). +// `newSecureDocumentBuilderFactory()` below disables DOCTYPE declarations +// entirely (which also rules out entity expansion, external or internal) and +// external general/parameter entities and XInclude as defense in depth. +// +// PARSING ALGORITHM: pre-written from §3.2 (the design's own instruction — +// "same fields... same TextQuoteSelector construction... no mammoth/rendered- +// HTML dependency needed at read time") and ported field-for-field from +// docx-comments.ts's `readDocxComments`/`walkDocument`/`parseCommentsXml`/ +// `parseCommentsExtendedXml`/`resolveRootParaId` — see each function's own +// comment for where it diverges from a literal line-for-line port (mostly: +// Kotlin's `Element.getAttribute` returns `""` for an absent attribute where +// the TS/DOM `getAttribute` returns `null`, so every attribute read here +// checks `hasAttribute` first wherever the TS code checked `!== null`). +package com.youcoded.app.doccomments + +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.Node +import java.io.File +import java.io.StringReader +import java.util.zip.ZipFile +import javax.xml.parsers.DocumentBuilder +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException +import org.xml.sax.InputSource +// Write-path (T17) imports — kept plain JDK/kotlinx.coroutines, no Android +// framework classes (mirrors the read path's own "runs in a plain JVM" pin, +// DocxCommentsTest.kt's `runsInAPlainJvmEnvironmentNoAndroidFrameworkClassInvolved`). +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap +import java.security.SecureRandom +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +/** Mirrors desktop's `DocxReadError` union (docx-comments.ts). `UNSAFE_XML` + * has no desktop counterpart yet (F1, implementation review, Android-only + * so far) — see `rejectDoctype`'s own doc comment for why this platform + * needs a check desktop's `linkedom`-based parser doesn't. */ +enum class DocxReadError { + INVALID_DOCX, + MISSING_DOCUMENT_PART, + ARCHIVE_TOO_LARGE, + UNSAFE_XML, +} + +sealed class DocxReadResult { + data class Ok(val comments: List) : DocxReadResult() + data class Err(val error: DocxReadError) : DocxReadResult() +} + +// §1.1's TextQuoteSelector doc comment: "~32 chars before/after, +// whitespace-collapsed" — same constants as docx-comments.ts. +private const val CONTEXT_CHARS = 32 +private const val RAW_WINDOW_MULTIPLIER = 4 + +private val WHITESPACE_RUN = Regex("\\s+") + +private fun collapseWhitespace(s: String): String = WHITESPACE_RUN.replace(s, " ") + +private fun buildPrefix(fullText: String, start: Int): String { + val rawStart = maxOf(0, start - CONTEXT_CHARS * RAW_WINDOW_MULTIPLIER) + val raw = fullText.substring(rawStart, start) + val collapsed = collapseWhitespace(raw) + return collapsed.substring(maxOf(0, collapsed.length - CONTEXT_CHARS)) +} + +private fun buildSuffix(fullText: String, end: Int): String { + val rawEnd = minOf(fullText.length, end + CONTEXT_CHARS * RAW_WINDOW_MULTIPLIER) + val raw = fullText.substring(end, rawEnd) + val collapsed = collapseWhitespace(raw) + return collapsed.substring(0, minOf(CONTEXT_CHARS, collapsed.length)) +} + +/** How many times `exact` (verbatim) appears in `fullText` strictly before + * `start` — informational/for-display only, exactly like desktop's own + * `countOccurrencesBefore` doc comment explains (`resolveSelector` scores + * every remaining occurrence itself rather than trusting this field). */ +private fun countOccurrencesBefore(fullText: String, exact: String, start: Int): Int { + if (exact.isEmpty()) return 0 + var count = 0 + var from = 0 + while (true) { + val idx = fullText.indexOf(exact, from) + if (idx == -1 || idx >= start) break + count++ + from = idx + 1 + } + return count +} + +/** One stack frame in `walkDocument`'s iterative walk — mirrors + * docx-comments.ts's `WalkFrame`. F3 (T10 implementation review, ported + * here even though it was never independently hit on the JVM's default + * stack size against the shared `deeply-nested.docx` fixture — see this + * file's own test for a stack-size-independent confirmation): a RECURSIVE + * visit()-per-element walk's call-stack depth grows with the DOM's NESTING + * depth, not its byte size, so an explicit heap-backed stack is used here + * for the same reason desktop's own walk was rewritten iterative, not + * because this specific fixture was observed to overflow a JVM thread's + * default stack (it doesn't — confirmed empirically while building this + * module — but Android's ART runtime is a different VM with its own + * stack-size defaults, and this walk has no reason to depend on either + * implementation's specific limit when an unbounded-depth version costs + * nothing extra to write). */ +private class WalkFrame(val el: Element, val children: List, var idx: Int) + +private class RangeInfo(var start: Int, var end: Int) + +/** T17's write-path addition to T16's `walkDocument` — a leaf is one + * contiguous span of `fullText` that came from a single DOM unit (a `` + * run's text, a single-character `w:tab`/`w:br`/`w:cr`, or the synthetic + * paragraph-break `\n` a `` contributes), gap-free by construction. + * Mirrors docx-comments.ts's `WriteLeaf` union — see that file's own doc + * comment for why this lets `resolveSelector`'s character OFFSETS be + * translated back into an exact DOM insertion point for add/move, something + * the read path's `ranges` map never needed (it only looks up an id Word + * ALREADY marked, never inserts one). `open` because it's a base class three + * concrete leaf kinds extend rather than a Kotlin `sealed` hierarchy, purely + * so `start`/`end` can live once in the base rather than being repeated in + * every subclass constructor. */ +// WHY a leaf keeps its own node and never a cached `` (2026-09-28 PR +// review, mirrors docx-comments.ts's `WriteLeaf`): splitting a run used to +// rebuild it from the one `` being cut, deleting every tab, break and +// image beside it, and then reused runs the split had removed. Splits now +// MOVE the real child nodes (`splitRunBeforeNode`), so a leaf's node is always +// still in the document and its current run is looked up when needed (`runOf`). +private open class WriteLeaf(val start: Int, val end: Int) { + class Text(start: Int, end: Int, val textEl: Element) : WriteLeaf(start, end) + class Atom(start: Int, end: Int, val node: Element) : WriteLeaf(start, end) + class ParaBreak(start: Int, end: Int, val paragraph: Element) : WriteLeaf(start, end) +} + +private class WalkResult(val fullText: String, val ranges: Map, val leaves: List?) + +/** Walks up from `el` (inclusive) for the nearest ancestor with `tagName`, or + * `null` if none exists before the document root. Used to find a ``'s + * owning `` — comment range markers are always inserted as RUN + * siblings, never inside a run's own children. Mirrors docx-comments.ts's + * `nearestAncestor`. */ +private fun nearestAncestor(el: Element, tagName: String): Element? { + var cur: Node? = el + while (cur != null) { + if (cur is Element && cur.tagName == tagName) return cur + cur = cur.parentNode + } + return null +} + +private fun elementChildren(el: Element): List { + val out = mutableListOf() + val nodes = el.childNodes + for (i in 0 until nodes.length) { + val n = nodes.item(i) + if (n.nodeType == Node.ELEMENT_NODE) out.add(n as Element) + } + return out +} + +/** + * Walks `` in document order building the same flat text a + * text-only viewer would render, straight off `document.xml`'s own markup — + * never mammoth's rendered HTML (this Kotlin module has no access to it and + * doesn't need it: `w:commentRangeStart`/`End` already mark exactly which run + * text a comment covers). Mirrors docx-comments.ts's `walkDocument` + * (read-path fields only — `leaves`/write-path collection is T17's scope). + * + * `w:commentRangeStart`/`End` are recorded by their `w:id` (never + * `w15:paraId` — that identifier only exists on `` elements, and only + * commentsExtended.xml's own entries key off it). + */ +/** + * `collectLeaves` (T17, write path only): also builds the `WriteLeaf[]` list + * above, in the SAME single pass that builds `fullText` — never a second, + * separately-maintained walk that could disagree with this one about what + * `fullText` actually contains. Defaults to `false`, unchanged behaviour for + * every existing (read-path) call site — mirrors docx-comments.ts's own + * `walkDocument(doc, { collectLeaves })` option. + */ +private fun walkDocument(doc: Document, collectLeaves: Boolean = false): WalkResult { + val ranges = LinkedHashMap() + val fullText = StringBuilder() + val leaves: MutableList? = if (collectLeaves) mutableListOf() else null + val bodies = doc.getElementsByTagName("w:body") + if (bodies.length == 0) return WalkResult("", ranges, leaves) + val body = bodies.item(0) as Element + + val stack = ArrayDeque() + stack.addLast(WalkFrame(body, elementChildren(body), 0)) + + while (stack.isNotEmpty()) { + val frame = stack.last() + if (frame.idx >= frame.children.size) { + // Post-order step: append the paragraph-break newline after every + // child of a `` has been visited — mirrors the old recursive + // shape's "append \n after the recursive call returns". + if (frame.el.tagName == "w:p") { + if (leaves != null) leaves.add(WriteLeaf.ParaBreak(fullText.length, fullText.length + 1, frame.el)) + fullText.append('\n') + } + stack.removeLast() + continue + } + val el = frame.children[frame.idx] + frame.idx++ + when (el.tagName) { + "w:t" -> { + val text = el.textContent ?: "" + // An empty contributes zero characters — no leaf needed + // (and none would be addressable by any offset anyway). + if (leaves != null && text.isNotEmpty()) { + leaves.add(WriteLeaf.Text(fullText.length, fullText.length + text.length, el)) + } + fullText.append(text) + } + // WHY the parent check (2026-09-28 PR review, mirrors desktop): + // `` also names a tab-STOP definition inside ``, + // which is layout, not a character. Only a run's own child is text. + "w:tab" -> if (frame.el.tagName == "w:r") { + if (leaves != null) leaves.add(WriteLeaf.Atom(fullText.length, fullText.length + 1, el)) + fullText.append('\t') + } + "w:br", "w:cr" -> if (frame.el.tagName == "w:r") { + if (leaves != null) leaves.add(WriteLeaf.Atom(fullText.length, fullText.length + 1, el)) + fullText.append('\n') + } + "w:commentRangeStart" -> { + if (el.hasAttribute("w:id")) { + val id = el.getAttribute("w:id") + ranges[id] = RangeInfo(fullText.length, fullText.length) + } + } + "w:commentRangeEnd" -> { + if (el.hasAttribute("w:id")) { + ranges[el.getAttribute("w:id")]?.let { it.end = fullText.length } + } + } + } + // Descend into this element's own children next (pre-order for this + // element's own tag handling above, matching the old `visit(el)` call). + stack.addLast(WalkFrame(el, elementChildren(el), 0)) + } + return WalkResult(fullText.toString(), ranges, leaves) +} + +private class RawComment( + val id: String, + val author: String, + val date: String, + val text: String, + val paraId: String?, +) + +private class ExtendedInfo(val done: Boolean, val paraIdParent: String?) + +/** A secure `DocumentBuilderFactory`: no DOCTYPE declarations (which also + * rules out entity expansion entirely, internal or external), no external + * general/parameter entities, no XInclude. Namespace-UNAWARE on purpose + * (the JDK default): every OOXML tag/attribute this module reads is + * addressed by its literal prefixed name (`"w:comment"`, `"w:id"`, + * `"w15:paraIdParent"`, ...), matching the way desktop's own `linkedom` + * `DOMParser` treats them (§3.2a: "the same DOM shape ... barely changes + * shape") — turning namespace-awareness ON would make `getTagName()` return + * only the local part and require namespace-URI-based lookups instead, + * which is not what this port's field-for-field mapping from + * docx-comments.ts assumes. */ +private fun newSecureDocumentBuilder(): DocumentBuilder { + val factory = DocumentBuilderFactory.newInstance() + factory.isNamespaceAware = false + factory.isXIncludeAware = false + factory.isExpandEntityReferences = false + // Disabling DOCTYPE declarations outright is OWASP's own recommended + // single strongest defense (it also implies no internal/external entity + // expansion and no external DTD fetch) — applied first; the two + // external-entity features below are defense in depth for a factory + // implementation that doesn't honor the DOCTYPE-disallow feature. + val secureFeatures = listOf( + "http://apache.org/xml/features/disallow-doctype-decl" to true, + "http://xml.org/sax/features/external-general-entities" to false, + "http://xml.org/sax/features/external-parameter-entities" to false, + "http://apache.org/xml/features/nonvalidating/load-external-dtd" to false, + ) + for ((feature, value) in secureFeatures) { + try { + factory.setFeature(feature, value) + } catch (_: ParserConfigurationException) { + // A given feature name not being recognized by whatever parser + // implementation is on the classpath is not fatal — the other + // features (and XIncludeAware/expandEntityReferences above) still + // apply. Never silently swallow a REAL parse failure though: + // this only catches the `setFeature` call itself. + } + } + // NOTE: the JDK's own `javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD`/ + // `ACCESS_EXTERNAL_SCHEMA` attribute constants (a common extra layer of + // XXE defense on desktop JVMs) do not exist on Android's `javax.xml` + // surface (`android.jar` has no such fields — confirmed by a compile + // failure against `compileSdk 35` while building this module). They are + // deliberately NOT used here. This is not a weaker posture: disabling + // DOCTYPE declarations outright (`disallow-doctype-decl`, set above) is + // OWASP's own recommended STRONGEST single defense against XXE, and + // already rules out every entity-expansion and external-DTD-fetch attack + // this attribute pair would otherwise guard against — the two features + // below it are redundant defense in depth for a parser that doesn't + // honor the DOCTYPE-disallow feature, not a substitute for it. + return factory.newDocumentBuilder() +} + +/** Thrown by `parseXml` when a part's raw XML text contains a ` { + val doc = parseXml(xml) + val nodes = doc.getElementsByTagName("w:comment") + val out = mutableListOf() + for (i in 0 until nodes.length) { + val el = nodes.item(i) as Element + val id = if (el.hasAttribute("w:id")) el.getAttribute("w:id") else "" + val author = if (el.hasAttribute("w:author")) el.getAttribute("w:author") else "" + val date = if (el.hasAttribute("w:date")) el.getAttribute("w:date") else "" + val texts = el.getElementsByTagName("w:t") + val text = StringBuilder() + for (j in 0 until texts.length) text.append((texts.item(j) as Element).textContent ?: "") + val paras = el.getElementsByTagName("w:p") + // Real Word writes this as `w14:paraId` on the comment's own first + // paragraph — the same identifier commentsExtended.xml's + // `w15:paraId` refers back to. + val paraId = if (paras.length > 0) { + val p = paras.item(0) as Element + if (p.hasAttribute("w14:paraId")) p.getAttribute("w14:paraId") else null + } else null + out.add(RawComment(id, author, date, text.toString(), paraId)) + } + return out +} + +private fun parseCommentsExtendedXml(xml: String): Map { + val doc = parseXml(xml) + val nodes = doc.getElementsByTagName("w15:commentEx") + val map = LinkedHashMap() + for (i in 0 until nodes.length) { + val el = nodes.item(i) as Element + if (!el.hasAttribute("w15:paraId")) continue + val paraId = el.getAttribute("w15:paraId") + val done = el.hasAttribute("w15:done") && el.getAttribute("w15:done") == "1" + val paraIdParent = if (el.hasAttribute("w15:paraIdParent")) el.getAttribute("w15:paraIdParent") else null + map[paraId] = ExtendedInfo(done, paraIdParent) + } + return map +} + +/** Every Word comment author is a real, named colleague from THIS reader's + * point of view (§3.4) — reading never has grounds to claim `'user'`/ + * `'assistant'`, which only apply to comments this app itself created (a + * write-path concern, T17). */ +private fun toCommentAuthor(name: String): CommentAuthor = "person:" + name.ifEmpty { "Unknown" } + +private fun parseDate(iso: String): Long = try { + java.time.Instant.parse(iso).toEpochMilli() +} catch (_: Exception) { + System.currentTimeMillis() +} + +/** Follows a `w15:paraIdParent` chain up to its root — a reply-to-a-reply + * collapses into the SAME flat `replies[]` array as a direct reply, since + * `PersistedComment` has no nested-thread shape. A cycle (malformed input) + * stops the walk rather than looping forever. */ +private fun resolveRootParaId(paraId: String, extended: Map): String { + var current = paraId + val seen = mutableSetOf() + while (true) { + if (!seen.add(current)) return current + val parent = extended[current]?.paraIdParent ?: return current + current = parent + } +} + +/** + * Reads every Word comment out of a `.docx` file on disk. `path` is the + * file's own (project-relative) path, stamped onto each returned record — + * these are never stored in a JSON sidecar (§1.1: Word/Excel comments live + * inside the file itself), but the field still lets a caller identify which + * file a record came from, the same as any other `PersistedComment`. + * + * Never throws on ordinary "nothing to read" shapes: a docx with no + * `word/comments.xml` part at all (§3.2: "a .docx may simply not have this + * part") returns an EMPTY list, not an error — same as desktop. + */ +fun readDocxComments(file: File, path: String): DocxReadResult { + val zip: ZipFile + try { + zip = ZipFile(file) + } catch (_: Exception) { + return DocxReadResult.Err(DocxReadError.INVALID_DOCX) + } + // F1/F3 (implementation review): `rejectDoctype` (thrown from `parseXml`, + // reached indirectly through `parseCommentsXml`/`parseCommentsExtendedXml`/ + // `walkDocument`) and `readEntryBounded` (DocCommentsZipSizeGuard.kt, F3's + // decompression-time backstop) both signal by throwing rather than by a + // sealed result, since they're reached several calls deep inside + // `readDocxCommentsFromZip`'s own pipeline — catching both here, in the + // ONE place this function actually returns a `DocxReadResult`, is what + // stays honest about the exception rather than a swallow-and-guess + // (`newSecureDocumentBuilder`'s own `setFeature` catch is the failure mode + // F1 exists to compensate for — this catch is never that: it's a REAL, + // logged-by-being-a-typed-result outcome, not a silently-ignored one). + return try { + zip.use { z -> readDocxCommentsFromZip(z, path) } + } catch (_: DocxUnsafeXmlDoctypeException) { + DocxReadResult.Err(DocxReadError.UNSAFE_XML) + } catch (_: ZipBombDetectedException) { + DocxReadResult.Err(DocxReadError.ARCHIVE_TOO_LARGE) + } +} + +private fun readDocxCommentsFromZip(z: ZipFile, path: String): DocxReadResult { + val commentsEntry = z.getEntry("word/comments.xml") + ?: return DocxReadResult.Ok(emptyList()) + val documentEntry = z.getEntry("word/document.xml") + // A comments.xml part with no document.xml at all is not a real + // Word file (every .docx has one) — refuse rather than silently + // reporting no comments for what is actually a corrupt archive. + ?: return DocxReadResult.Err(DocxReadError.MISSING_DOCUMENT_PART) + val extendedEntry = z.getEntry("word/commentsExtended.xml") + + // F2-equivalent (zip-size-guard): refuse a decompression-bomb-shaped + // archive BEFORE any entry is decompressed, checked against the + // CENTRAL DIRECTORY's declared size, never against actually- + // decompressed bytes. Kept as the fast first line of defense; F3's + // `readEntryBounded` call just below is the backstop that doesn't + // trust this declared metadata at all. + val sizeCheck = checkNamedEntriesWithinCeiling( + z, + listOf("word/comments.xml", "word/document.xml", "word/commentsExtended.xml"), + ) + if (sizeCheck is ZipSizeGuardResult.ArchiveTooLarge) { + return DocxReadResult.Err(DocxReadError.ARCHIVE_TOO_LARGE) + } + + // F3: bounded, byte-counting decompression (DocCommentsZipSizeGuard.kt) + // — counts REAL decompressed bytes as they arrive and throws + // `ZipBombDetectedException` (caught by `readDocxComments` above) the + // moment they exceed the ceiling, regardless of what the archive's own + // metadata declared. + val commentsXml = readEntryBounded(z, commentsEntry).toString(Charsets.UTF_8) + val documentXml = readEntryBounded(z, documentEntry).toString(Charsets.UTF_8) + val extendedXml = extendedEntry?.let { readEntryBounded(z, it).toString(Charsets.UTF_8) } + + val rawComments = parseCommentsXml(commentsXml) + val extended = extendedXml?.let { parseCommentsExtendedXml(it) } ?: emptyMap() + val walked = walkDocument(parseXml(documentXml)) + val fullText = walked.fullText + val ranges = walked.ranges + + val byParaId = HashMap() + for (c in rawComments) if (c.paraId != null) byParaId[c.paraId] = c + + // Group every raw comment.xml entry into its root's replies[] (if + // it's a reply, however deeply nested) or the top-level list (if + // it's a root). + val repliesByRootParaId = HashMap>() + val topLevel = mutableListOf() + for (c in rawComments) { + val rootParaId = c.paraId?.let { resolveRootParaId(it, extended) } + val isReply = rootParaId != null && rootParaId != c.paraId && byParaId.containsKey(rootParaId) + if (isReply) { + val root = byParaId.getValue(rootParaId!!) + val list = repliesByRootParaId.getOrPut(rootParaId) { mutableListOf() } + list.add( + CommentReply( + id = "w-${root.id}-r${list.size + 1}", + author = toCommentAuthor(c.author), + text = c.text, + createdAt = parseDate(c.date), + ), + ) + } else { + topLevel.add(c) + } + } + + val comments = topLevel.map { c -> + val range = ranges[c.id] + val ext = c.paraId?.let { extended[it] } + val exact = if (range != null) fullText.substring(range.start, range.end) else "" + val selector = CommentSelector.Text( + TextQuoteSelector( + exact = exact, + prefix = if (range != null) buildPrefix(fullText, range.start) else "", + suffix = if (range != null) buildSuffix(fullText, range.end) else "", + occurrence = if (range != null) countOccurrencesBefore(fullText, exact, range.start) else 0, + ), + ) + PersistedComment( + id = "w-${c.id}", + path = path, + selector = selector, + text = c.text, + author = toCommentAuthor(c.author), + createdAt = parseDate(c.date), + replies = c.paraId?.let { repliesByRootParaId[it] } ?: emptyList(), + resolved = ext?.done ?: false, + // Word's own OOXML has no separate resolve/reopen AUDIT + // TRAIL — only the CURRENT w15:done bit. `history` starts + // empty for a freshly-read native comment (write path, T17, + // is what would append to it). + history = emptyList(), + ) + } + + return DocxReadResult.Ok(comments) +} + +// ============================================================================= +// Word (.docx) comment WRITING on Android — T17 of the doc-comments build +// (docs/active/specs/2026-09-26-doc-comments-build-design.md §3.2a/§3.3, §8 +// T17). add / reply / resolve / reopen / move, mirroring desktop's own +// docx-comments.ts write section (T11, post-review-fixes commit c496576bd and +// later) field-for-field: id/paraId uniqueness scanned fresh from the file +// (never assumed monotonic), the Word 2016+ commentsIds.xml/ +// commentsExtensible.xml entries when (and only when) already present, the +// move/repoint algorithm (review 3, F2), byte-identical pass-through of every +// part this operation didn't touch, verify-by-reread with THIS file's own +// `readDocxComments`, and a rolling backup outside any project. +// +// WHY the write pipeline below (`writeDocxMutation`) verifies BEFORE ever +// touching the real target file, rather than desktop's write-then-verify- +// then-restore-from-backup shape: Kotlin builds the mutated archive fully +// in-memory/on-a-scratch-file first, so "verify by reread" can run against +// that SCRATCH file (never the live target) before any real replace happens. +// This gives the identical outward guarantee §3.3 step 6 requires — a failed +// write leaves the target byte-for-byte what it was before, never a +// half-written third state — through a simpler mechanism than an actual +// disk-level rollback: there is nothing to roll back, because the target was +// never written to in the first place. The backup file (step 1) is still +// written unconditionally before the real replace, as the same STANDING +// safety net the design requires (kept after success, not just for this +// write's own failure path) — it is not what makes verify-failure safe here, +// that's simply "don't do the replace." +// +// WHY cross-process locking (com.youcoded.app.artifacts.CasWrite.kt's +// `mutateFileUnderLock`) is deliberately NOT used for the real `.docx` target +// file itself, only an in-process kotlinx.coroutines Mutex per absolute path: +// design §1.5 ("Kotlin's own file-locking") works through this exact question +// and its answer is explicit — "unlike the JSON sidecar, the MCP script never +// touches a .docx/.xlsx file directly — it goes through the pending-mutation +// queue (§9.2, T20), whose applier is SessionService's own polling loop +// running in the SAME process as every Kotlin-originated write... Android +// never needs the desktop main process's cross-process mkdir-lock for THIS +// path, only ordinary in-process exclusion." An in-process Mutex keyed by +// absolute path is therefore already the CORRECT and SUFFICIENT primitive per +// the design's own reasoning — reaching for the mkdir-based cross-process +// lock here would add real overhead (extra syscalls, a `.lock` directory +// racing on every write) to guard against a process that structurally cannot +// reach this file directly. The ATOMIC-REPLACE mechanics below (write to a +// sibling `.tmp` path, then `Files.move` with `ATOMIC_MOVE`, falling back to +// `REPLACE_EXISTING`) still mirror `CasWrite.kt`'s own shape exactly, so this +// module follows that file's PATTERN even where it doesn't call its lock. +// ============================================================================= + +private const val W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +private const val W14_NS = "http://schemas.microsoft.com/office/word/2010/wordml" +private const val W15_NS = "http://schemas.microsoft.com/office/word/2012/wordml" +private const val RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +private const val COMMENTS_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml" +private const val COMMENTS_EXT_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml" +private const val COMMENTS_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" +private const val COMMENTS_EXT_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/commentsExtended" + +private val EMPTY_COMMENTS_XML = + """""" +private val EMPTY_EXTENDED_XML = + """""" +private val EMPTY_RELS_XML = + """""" + +/** Mirrors desktop's `DocxWriteError` union (docx-comments.ts) — every value + * carries its own wire string so a dispatch layer never re-derives one from + * the enum name (avoiding the read path's `.name.lowercase().replace(...)` + * trick drifting from desktop's own literal strings for a NEW error). */ +enum class DocxWriteError(val wire: String) { + INVALID_DOCX("invalid-docx"), + MISSING_DOCUMENT_PART("missing-document-part"), + ARCHIVE_TOO_LARGE("archive-too-large"), + UNSAFE_XML("unsafe-xml"), + COMMENT_NOT_FOUND("comment-not-found"), + SELECTOR_NOT_FOUND("selector-not-found"), + INVALID_SELECTOR("invalid-selector"), + READ_FAILED("read-failed"), + BACKUP_FAILED("backup-failed"), + WRITE_FAILED("write-failed"), + VERIFY_FAILED("verify-failed"), + // T17 follow-up (design §3.3's new step 0, review round 3 F4): the SAME + // pre-write lock-file check desktop's write-pipeline.ts now does — a + // plain sibling-file check via Kotlin's own file APIs, refused BEFORE + // step 1 (backup) even runs. See `isFileOpenElsewhere` below. + FILE_OPEN_ELSEWHERE("file-open-elsewhere"), +} + +/** Kotlin port of desktop's `xml-text-safety.ts` — see that file's own + * header for the full XML 1.0 well-formedness citation (control characters + * U+0000-U+0008, U+000B-U+000C, U+000E-U+001F are illegal EVERYWHERE in a + * well-formed document, even as a numeric character reference; tab/LF/CR + * are the only C0 codepoints excluded). + * + * Originally (commit ffda4b654) this REFUSED text containing one of these + * characters — a standalone JDK 21 probe (`javax.xml.parsers`/ + * `javax.xml.transform`, the exact APIs this module uses) confirmed + * Kotlin's failure mode without the check is a crash, not silent + * corruption: `Document.createElement`/`.setTextContent()` accept an + * illegal control character with no complaint, but + * `TransformerFactory.newTransformer().transform(...)` — the SAME call + * `serializePart` below uses — THROWS `TransformerException` the moment it + * tries to SERIALIZE that text, uncaught, out of a suspend function with no + * try/catch around it. + * + * Changed 2026-09-27 to STRIP those characters instead (matching desktop's + * own xml-text-safety.ts change, same reasoning): a user has no way to see + * or remove one of these characters themselves (they arrive via paste), so + * a refusal they can't act on is worse than silently dropping a handful of + * invisible bytes — the visible text is unaffected either way. Stripping + * BEFORE ever constructing a text node still avoids the crash above; it + * just no longer needs a typed refusal to do it. */ +private val ILLEGAL_XML_CHAR_REGEX = Regex("[\u0000-\u0008\u000B\u000C\u000E-\u001F]") + +private fun stripIllegalXmlChars(text: String): String = text.replace(ILLEGAL_XML_CHAR_REGEX, "") + +sealed class DocxWriteResult { + data class Ok(val value: T) : DocxWriteResult() + data class Err(val error: DocxWriteError) : DocxWriteResult() +} + +/** Copies every ATTRIBUTE (not child element) from `from` onto `to` — used by + * `splitRunBeforeNode` so a run's own `` + * attributes survive a split, not just its `w:rPr` CHILD (a real Word 365 + * file always carries rsid attributes on its runs). Mirrors docx-comments.ts's + * `copyAttributes` (T11 review, F3). */ +private fun copyAttributes(from: Element, to: Element) { + val attrs = from.attributes ?: return + for (i in 0 until attrs.length) { + val attr = attrs.item(i) + to.setAttribute(attr.nodeName, attr.nodeValue ?: "") + } +} + +private fun elementsByTag(doc: Document, tag: String): List { + val nodes = doc.getElementsByTagName(tag) + return (0 until nodes.length).map { nodes.item(it) as Element } +} + +private fun elementsByTag(el: Element, tag: String): List { + val nodes = el.getElementsByTagName(tag) + return (0 until nodes.length).map { nodes.item(it) as Element } +} + +private fun findByAttr(doc: Document, tag: String, attr: String, value: String): Element? = + elementsByTag(doc, tag).find { it.hasAttribute(attr) && it.getAttribute(attr) == value } + +/** `"w-3"` -> `"3"`; `null` for anything not shaped like a Word-native id + * this reader's own `readDocxComments` mints (`id: "w-${c.id}"`). A reply id + * (`w-3-r1`) is never a valid target for reply/resolve/reopen/move — those + * always act on a whole THREAD, never a single reply within it. */ +private fun stripWPrefix(id: String): String? = Regex("^w-([^-]+)$").find(id)?.groupValues?.get(1) + +/** §3.4: a reply/add made from this app must round-trip back into `w:author` + * naming "the account's display name or 'You'" — NEVER overwriting a + * colleague's own original `w:author` (only ever called to author a + * BRAND-NEW ``). No accounts exist yet (§1.2), so `'user'` is + * literally "You"; `'assistant'` is a plain, honest label. */ +private fun commentAuthorToDisplayName(author: CommentAuthor): String = when { + author == "user" -> "You" + author == "assistant" -> "Assistant" + author.startsWith("person:") -> author.removePrefix("person:").ifEmpty { "Unknown" } + else -> "Unknown" +} + +private fun maxExistingCommentId(commentsDoc: Document): Int { + var max = -1 + for (el in elementsByTag(commentsDoc, "w:comment")) { + val n = el.getAttribute("w:id").toIntOrNull() + if (n != null && n > max) max = n + } + return max +} + +private fun findCommentParaId(commentsDoc: Document, rawId: String): String? { + val el = findByAttr(commentsDoc, "w:comment", "w:id", rawId) ?: return null + val p = elementsByTag(el, "w:p").firstOrNull() ?: return null + return if (p.hasAttribute("w14:paraId")) p.getAttribute("w14:paraId") else null +} + +/** Loaded, mutable in-memory representation of every part a write might + * touch. Mirrors desktop's `LoadedArchive` (docx-comments.ts) field-for- + * field, including its `*Touched`/`*Changed`/`*Original` bookkeeping — + * see that type's own doc comment for why each exists (an untouched part + * must be written back byte-identical; a part that started absent and stays + * untouched by THIS operation must stay absent from the output, never gain + * an orphan empty shell). Every OTHER part in the archive (images, + * styles.xml, numbering.xml, ...) is never parsed or touched — `zip` (the + * source archive, kept OPEN by the caller across load+mutate+serialize, see + * `loadMutateSerialize`) is consulted directly at serialize time so those + * parts can be streamed straight through instead of ever being decompressed + * into memory here (review F1, android-xlsx-review — the same "decompresses + * and re-compresses EVERY part, not just the ones a mutation touches" bug + * confirmed present in this module too, not just `XlsxComments.kt`; this + * REPLACES the prior `entries: LinkedHashMap` field, + * which used to `readEntryBounded` — fully decompress — every non-tracked + * entry unconditionally). */ +private class LoadedArchive( + val zip: ZipFile, + val documentDoc: Document, + val documentXmlOriginal: String, + var documentChanged: Boolean = false, + val commentsDoc: Document, + val commentsIsNew: Boolean, + var commentsTouched: Boolean, + val commentsXmlOriginal: String?, + var commentsChanged: Boolean = false, + val extendedDoc: Document, + val extendedIsNew: Boolean, + var extendedTouched: Boolean, + val extendedXmlOriginal: String?, + var extendedChanged: Boolean = false, + val contentTypesDoc: Document, + val contentTypesXmlOriginal: String, + var contentTypesChanged: Boolean = false, + val relsDoc: Document, + val relsXmlOriginal: String?, + var relsTouched: Boolean, + var relsChanged: Boolean = false, + val commentsIdsDoc: Document?, + val commentsIdsXmlOriginal: String?, + var commentsIdsChanged: Boolean = false, + val commentsExtensibleDoc: Document?, + val commentsExtensibleXmlOriginal: String?, + var commentsExtensibleChanged: Boolean = false, +) + +/** Union of every `w14:paraId`/`w15:paraId` already in use anywhere in the + * archive — document.xml's own paragraphs, comments.xml's comment + * paragraphs, AND commentsExtended.xml's own entries — so a freshly + * generated one can never collide with ANY of them. Mirrors + * `collectAllParaIds` (docx-comments.ts). */ +private fun collectAllParaIds(archive: LoadedArchive): Set { + val set = mutableSetOf() + for (doc in listOf(archive.documentDoc, archive.commentsDoc)) { + for (el in elementsByTag(doc, "w:p")) { + if (el.hasAttribute("w14:paraId")) set.add(el.getAttribute("w14:paraId")) + } + } + for (el in elementsByTag(archive.extendedDoc, "w15:commentEx")) { + if (el.hasAttribute("w15:paraId")) set.add(el.getAttribute("w15:paraId")) + } + return set +} + +private val secureRandom = SecureRandom() + +private fun randomHex8(): String { + val bytes = ByteArray(4) + secureRandom.nextBytes(bytes) + return bytes.joinToString("") { "%02X".format(it) } +} + +/** An 8-hex-digit value "the way Word itself does" — never sequential, + * re-rolled on the rare collision against every paraId already in the + * archive. Mirrors `generateParaId` (docx-comments.ts, F6). */ +private fun generateParaId(existing: Set): String { + repeat(100) { + val candidate = randomHex8() + if (!existing.contains(candidate)) return candidate + } + throw IllegalStateException("docx-comments: could not generate a unique paraId") +} + +/** Union of every `w16cid:durableId`/`w16cex:durableId` already in use. + * Mirrors `collectAllDurableIds` (docx-comments.ts, F4). */ +private fun collectAllDurableIds(archive: LoadedArchive): Set { + val set = mutableSetOf() + archive.commentsIdsDoc?.let { doc -> + for (el in elementsByTag(doc, "w16cid:commentId")) { + if (el.hasAttribute("w16cid:durableId")) set.add(el.getAttribute("w16cid:durableId")) + } + } + archive.commentsExtensibleDoc?.let { doc -> + for (el in elementsByTag(doc, "w16cex:commentExtensible")) { + if (el.hasAttribute("w16cex:durableId")) set.add(el.getAttribute("w16cex:durableId")) + } + } + return set +} + +private fun generateDurableId(existing: Set): String { + repeat(100) { + val candidate = randomHex8() + if (!existing.contains(candidate)) return candidate + } + throw IllegalStateException("docx-comments: could not generate a unique durableId") +} + +private fun appendCommentIdsEntry(doc: Document, paraId: String, durableId: String) { + val el = doc.createElement("w16cid:commentId") + el.setAttribute("w16cid:paraId", paraId) + el.setAttribute("w16cid:durableId", durableId) + doc.documentElement.appendChild(el) +} + +private fun appendCommentExtensibleEntry(doc: Document, durableId: String, dateUtc: String) { + val el = doc.createElement("w16cex:commentExtensible") + el.setAttribute("w16cex:durableId", durableId) + el.setAttribute("w16cex:dateUtc", dateUtc) + doc.documentElement.appendChild(el) +} + +/** F4: adds matching entries to whichever of commentsIds.xml/ + * commentsExtensible.xml is actually present for a BRAND-NEW `` + * this operation just created (add or reply). Does nothing if NEITHER part + * exists — this module never CREATES them. Mirrors + * `recordCommentExtensionParts` (docx-comments.ts). */ +private fun recordCommentExtensionParts(archive: LoadedArchive, paraId: String) { + if (archive.commentsIdsDoc == null && archive.commentsExtensibleDoc == null) return + val durableId = generateDurableId(collectAllDurableIds(archive)) + archive.commentsIdsDoc?.let { + appendCommentIdsEntry(it, paraId, durableId) + archive.commentsIdsChanged = true + } + archive.commentsExtensibleDoc?.let { + appendCommentExtensibleEntry(it, durableId, isoNow()) + archive.commentsExtensibleChanged = true + } +} + +private fun nextRelId(relsDoc: Document): String { + var max = 0 + for (el in elementsByTag(relsDoc, "Relationship")) { + val m = Regex("^rId(\\d+)$").find(el.getAttribute("Id")) + if (m != null) max = maxOf(max, m.groupValues[1].toInt()) + } + return "rId${max + 1}" +} + +private fun addContentTypeOverride(contentTypesDoc: Document, partName: String, contentType: String) { + val exists = elementsByTag(contentTypesDoc, "Override").any { it.getAttribute("PartName") == partName } + if (exists) return + val el = contentTypesDoc.createElement("Override") + el.setAttribute("PartName", partName) + el.setAttribute("ContentType", contentType) + contentTypesDoc.documentElement.appendChild(el) +} + +private fun addRelationship(relsDoc: Document, type: String, target: String) { + val exists = elementsByTag(relsDoc, "Relationship").any { it.getAttribute("Target") == target } + if (exists) return + val el = relsDoc.createElement("Relationship") + el.setAttribute("Id", nextRelId(relsDoc)) + el.setAttribute("Type", type) + el.setAttribute("Target", target) + relsDoc.documentElement.appendChild(el) +} + +/** §3.3 step 2 — called only when `archive.commentsIsNew`. Mirrors + * `ensureCommentsPart` (docx-comments.ts) EXACTLY, including its own + * narrow scope: it does not set `relsTouched` — see that function's own doc + * comment above `LoadedArchive.relsTouched` in the TS source for why (a file + * with literally no pre-existing rels part at all is a structurally unusual + * shape no real fixture exercises; porting the identical behaviour here + * keeps this module's parity claim honest rather than "fixing" an edge case + * the reference implementation itself doesn't handle). */ +private fun ensureCommentsPart(archive: LoadedArchive) { + addContentTypeOverride(archive.contentTypesDoc, "/word/comments.xml", COMMENTS_CONTENT_TYPE) + addRelationship(archive.relsDoc, COMMENTS_REL_TYPE, "comments.xml") + archive.commentsTouched = true + archive.contentTypesChanged = true + archive.relsChanged = true +} + +private fun ensureExtendedPart(archive: LoadedArchive) { + addContentTypeOverride(archive.contentTypesDoc, "/word/commentsExtended.xml", COMMENTS_EXT_CONTENT_TYPE) + addRelationship(archive.relsDoc, COMMENTS_EXT_REL_TYPE, "commentsExtended.xml") + archive.extendedTouched = true + archive.contentTypesChanged = true + archive.relsChanged = true +} + +/** WHY (2026-09-28 PR review, mirrors docx-comments.ts): a comments.xml from + * Word 2007, LibreOffice or a Google Docs export never declares `w14`, and + * writing `w14:paraId` without it made XML Word calls damaged. A no-op when + * already declared. */ +private fun ensureW14NamespaceDeclared(doc: Document) { + if (!doc.documentElement.hasAttribute("xmlns:w14")) { + doc.documentElement.setAttribute("xmlns:w14", W14_NS) + } +} + +private fun appendCommentEntry(commentsDoc: Document, id: Int, author: String, date: String, paraId: String, text: String) { + ensureW14NamespaceDeclared(commentsDoc) + val commentEl = commentsDoc.createElement("w:comment") + commentEl.setAttribute("w:id", id.toString()) + commentEl.setAttribute("w:author", author) + commentEl.setAttribute("w:date", date) + val pEl = commentsDoc.createElement("w:p") + pEl.setAttribute("w14:paraId", paraId) + val rEl = commentsDoc.createElement("w:r") + val tEl = commentsDoc.createElement("w:t") + tEl.setAttribute("xml:space", "preserve") + tEl.textContent = text + rEl.appendChild(tEl) + pEl.appendChild(rEl) + commentEl.appendChild(pEl) + commentsDoc.documentElement.appendChild(commentEl) +} + +/** §3.3 step 4 (and step 3's `w15:paraIdParent`): update an EXISTING + * `w15:commentEx` entry in place when one already exists for `paraId` + * (never duplicated), or create a fresh one when it doesn't. Mirrors + * `upsertExtendedEntry` (docx-comments.ts). */ +private fun upsertExtendedEntry(extendedDoc: Document, paraId: String, done: Boolean, paraIdParent: String?) { + val existing = findByAttr(extendedDoc, "w15:commentEx", "w15:paraId", paraId) + val el = existing ?: extendedDoc.createElement("w15:commentEx") + el.setAttribute("w15:paraId", paraId) + el.setAttribute("w15:done", if (done) "1" else "0") + if (paraIdParent != null) el.setAttribute("w15:paraIdParent", paraIdParent) + if (existing == null) extendedDoc.documentElement.appendChild(el) +} + +/** The `` currently holding `node`, looked up fresh (see `WriteLeaf`). + * Mirrors `runOf` (docx-comments.ts). */ +private fun runOf(node: Element): Element? = nearestAncestor(node, "w:r") + +private class InsertionAnchor(val parent: Element, val before: Node?) + +/** + * The DOM position immediately BEFORE `child`, a content child of a ``, + * splitting the run there when `child` isn't its first content child. The new + * second run copies the original's attributes (rsids — F3) and `w:rPr`, then + * `child` and every node after it are MOVED into it — tabs, breaks, images + * and further text all survive (2026-09-28 PR review). Mirrors + * `splitRunBeforeNode` (docx-comments.ts). + */ +private fun splitRunBeforeNode(doc: Document, child: Element): InsertionAnchor { + val run = runOf(child) + if (run == null || child.parentNode !== run) { + return InsertionAnchor(child.parentNode as Element, child) + } + val parent = run.parentNode as Element + var hasContentBefore = false + var sib = child.previousSibling + while (sib != null) { + if (sib.nodeType == Node.ELEMENT_NODE && (sib as Element).tagName != "w:rPr") { + hasContentBefore = true + break + } + sib = sib.previousSibling + } + if (!hasContentBefore) return InsertionAnchor(parent, run) + + val newRun = doc.createElement("w:r") + copyAttributes(run, newRun) + val rPr = elementChildren(run).firstOrNull { it.tagName == "w:rPr" } + if (rPr != null) newRun.appendChild(rPr.cloneNode(true)) + var cur: Node? = child + while (cur != null) { + val next = cur.nextSibling + newRun.appendChild(cur) + cur = next + } + parent.insertBefore(newRun, run.nextSibling) + return InsertionAnchor(parent, newRun) +} + +/** Cuts a `` at `offset` (strictly inside it): the original keeps the + * text BEFORE the cut; a new `` with the rest follows it in the same + * run. Mirrors `cutTextNode` (docx-comments.ts). */ +private fun cutTextNode(doc: Document, textEl: Element, offset: Int): Element { + val text = textEl.textContent ?: "" + val tail = doc.createElement("w:t") + tail.setAttribute("xml:space", "preserve") + tail.textContent = text.substring(offset) + textEl.textContent = text.substring(0, offset) + textEl.setAttribute("xml:space", "preserve") + textEl.parentNode.insertBefore(tail, textEl.nextSibling) + return tail +} + +/** Where a marker for character `offset` goes: right before the character at + * `offset`. Mirrors `anchorForOffset` (docx-comments.ts). */ +private fun anchorForOffset(doc: Document, leaves: List, offset: Int): InsertionAnchor { + val idx = leaves.indexOfFirst { offset >= it.start && offset < it.end } + if (idx == -1) { + // offset === fullText.length (or the document has no leaves at all, + // which resolveSelector already ruled out by finding a match). + val last = leaves.lastOrNull() ?: throw IllegalStateException("docx-comments: no leaves to anchor an insertion against") + if (last is WriteLeaf.ParaBreak) return InsertionAnchor(last.paragraph, null) + val node = when (last) { + is WriteLeaf.Text -> last.textEl + is WriteLeaf.Atom -> last.node + else -> throw IllegalStateException("docx-comments: unreachable leaf kind") + } + val run = runOf(node) ?: node + return InsertionAnchor(run.parentNode as Element, run.nextSibling) + } + return when (val leaf = leaves[idx]) { + // The only reachable offset here is leaf.start — "immediately after + // the last real content of the paragraph that just closed". + is WriteLeaf.ParaBreak -> InsertionAnchor(leaf.paragraph, null) + is WriteLeaf.Atom -> splitRunBeforeNode(doc, leaf.node) + is WriteLeaf.Text -> { + val offsetInText = offset - leaf.start + if (offsetInText == 0) splitRunBeforeNode(doc, leaf.textEl) + else splitRunBeforeNode(doc, cutTextNode(doc, leaf.textEl, offsetInText)) + } + else -> throw IllegalStateException("docx-comments: unreachable leaf kind") + } +} + +private fun buildCommentReferenceRun(doc: Document, id: String): Element { + val refRun = doc.createElement("w:r") + val rPr = doc.createElement("w:rPr") + val rStyle = doc.createElement("w:rStyle") + rStyle.setAttribute("w:val", "CommentReference") + rPr.appendChild(rStyle) + refRun.appendChild(rPr) + val ref = doc.createElement("w:commentReference") + ref.setAttribute("w:id", id) + refRun.appendChild(ref) + return refRun +} + +/** + * Inserts a `w:commentRangeStart`/`w:commentRangeEnd` pair for `id` at + * `[start, end)`, plus the `w:commentReference` run right after the end + * marker. The END is placed first: `cutTextNode` keeps the text before a cut + * in the original node, so the leaf table built before any edit still + * describes every character the start placement looks up. Mirrors + * `insertCommentRangeMarkers` (docx-comments.ts). + */ +private fun insertCommentRangeMarkers(doc: Document, leaves: List, start: Int, end: Int, id: String) { + val startMarker = doc.createElement("w:commentRangeStart") + startMarker.setAttribute("w:id", id) + val endMarker = doc.createElement("w:commentRangeEnd") + endMarker.setAttribute("w:id", id) + val refRun = buildCommentReferenceRun(doc, id) + + val endAnchor = anchorForOffset(doc, leaves, end) + endAnchor.parent.insertBefore(endMarker, endAnchor.before) + (endMarker.parentNode as Element).insertBefore(refRun, endMarker.nextSibling) + val startAnchor = anchorForOffset(doc, leaves, start) + startAnchor.parent.insertBefore(startMarker, startAnchor.before) +} + +/** §3.3 step 5 (Move, review 3 F2): removes the CURRENT + * `w:commentRangeStart`/`End` pair and its paired `w:commentReference` run, + * all matched by `id`. Returns `false` when any of the three pieces can't be + * found. Mirrors `removeCommentRangeAndReference` (docx-comments.ts). */ +private fun removeCommentRangeAndReference(doc: Document, id: String): Boolean { + val start = findByAttr(doc, "w:commentRangeStart", "w:id", id) + val end = findByAttr(doc, "w:commentRangeEnd", "w:id", id) + val ref = findByAttr(doc, "w:commentReference", "w:id", id) + if (start == null || end == null || ref == null) return false + (start.parentNode as? Element)?.removeChild(start) + (end.parentNode as? Element)?.removeChild(end) + val refRun = nearestAncestor(ref, "w:r") ?: ref + (refRun.parentNode as? Element)?.removeChild(refRun) + return true +} + +// ----------------------------------------------------------------------- +// Edit/delete build (2026-09-28, design doc §"Edit and delete"): anyone's +// comment/reply can be edited or deleted, no "edited" marker is ever stored +// or shown, and deleting a THREAD's first comment deletes the whole thread +// — decisions.json (doc-comments.edit-delete.questions.answers.json). +// Mirrors docx-comments.ts's own "Edit/delete build" section field-for-field. +// ----------------------------------------------------------------------- + +/** Shared across every reply-id convention this feature mints (the plain + * JSON sidecar's `${commentId}-r${n}`, docx's `w-{id}-r{n}`, xlsx's + * `xt-...-r{n}`) — the ordinal is always the trailing `-r`. Mirrors + * `replyOrdinalFromId` (docx-comments.ts). */ +private fun replyOrdinalFromId(replyId: String): Int? = Regex("-r(\\d+)$").find(replyId)?.groupValues?.get(1)?.toIntOrNull() + +/** Removes every `w:p` child from `commentEl` and rewrites it with ONE + * paragraph carrying `text` — mirrors `appendCommentEntry`'s own shape + * exactly, so an edited comment/reply round-trips through the read path + * identically to a freshly-added one. Only CHILDREN are touched: `w:id`/ + * `w:author`/`w:date` (and any `w:initials` a real Word file carries) stay + * on the element's own attributes, untouched — "keep author/date/initials" + * per the task brief, satisfied by never looking at attributes at all. The + * original `w14:paraId` is preserved (never re-minted) since every OTHER + * part (commentsExtended.xml, commentsIds.xml, commentsExtensible.xml) is + * keyed off it. Mirrors `replaceCommentParagraphText` (docx-comments.ts). */ +private fun replaceCommentParagraphText(commentsDoc: Document, commentEl: Element, text: String) { + val paras = elementsByTag(commentEl, "w:p") + val paraId = paras.firstOrNull()?.let { if (it.hasAttribute("w14:paraId")) it.getAttribute("w14:paraId") else null } + for (p in paras) { + (p.parentNode as? Element)?.removeChild(p) + } + val pEl = commentsDoc.createElement("w:p") + if (paraId != null) { + ensureW14NamespaceDeclared(commentsDoc) + pEl.setAttribute("w14:paraId", paraId) + } + val rEl = commentsDoc.createElement("w:r") + val tEl = commentsDoc.createElement("w:t") + tEl.setAttribute("xml:space", "preserve") + tEl.textContent = text + rEl.appendChild(tEl) + pEl.appendChild(rEl) + commentEl.appendChild(pEl) +} + +/** Mirrors `buildExtendedMap` (docx-comments.ts). */ +private fun buildExtendedMap(extendedDoc: Document): Map { + val map = LinkedHashMap() + for (el in elementsByTag(extendedDoc, "w15:commentEx")) { + if (!el.hasAttribute("w15:paraId")) continue + val paraId = el.getAttribute("w15:paraId") + val done = el.hasAttribute("w15:done") && el.getAttribute("w15:done") == "1" + val paraIdParent = if (el.hasAttribute("w15:paraIdParent")) el.getAttribute("w15:paraIdParent") else null + map[paraId] = ExtendedInfo(done, paraIdParent) + } + return map +} + +/** Ordered list of a root's reply `` elements, in the SAME file + * order `readDocxComments`/`nextReplyOrdinal` assign ordinals in — shared + * by `nextReplyOrdinal` (count), edit-reply/delete-reply (index by + * ordinal), and delete-thread (collect all, order irrelevant there). + * Mirrors `collectReplyEntries` (docx-comments.ts). */ +private fun collectReplyEntries(archive: LoadedArchive, rootParaId: String): List { + val extended = buildExtendedMap(archive.extendedDoc) + val out = mutableListOf() + for (el in elementsByTag(archive.commentsDoc, "w:comment")) { + val p = elementsByTag(el, "w:p").firstOrNull() + val paraId = p?.let { if (it.hasAttribute("w14:paraId")) it.getAttribute("w14:paraId") else null } + if (paraId == null || paraId == rootParaId) continue + if (resolveRootParaId(paraId, extended) == rootParaId) out.add(el) + } + return out +} + +private fun removeElement(el: Element) { + (el.parentNode as? Element)?.removeChild(el) +} + +private fun removeExtendedEntry(extendedDoc: Document, paraId: String) { + val el = findByAttr(extendedDoc, "w15:commentEx", "w15:paraId", paraId) + if (el != null) removeElement(el) +} + +/** Mirrors `recordCommentExtensionParts`'s own pairing (F4): `commentsIds.xml` + * keys an entry by `w14:paraId` directly, and `commentsExtensible.xml` keys + * its OWN entry by the durable id that `commentsIds.xml`'s entry paired + * with that same paraId — so the extensible entry can only be found by + * reading the durable id back off the ids entry first. Both parts are only + * ever edited when already present (this module never creates either). + * Mirrors `removeCommentExtensionEntries` (docx-comments.ts). */ +private fun removeCommentExtensionEntries(archive: LoadedArchive, paraId: String) { + var durableId: String? = null + archive.commentsIdsDoc?.let { doc -> + val el = findByAttr(doc, "w16cid:commentId", "w16cid:paraId", paraId) + if (el != null) { + durableId = if (el.hasAttribute("w16cid:durableId")) el.getAttribute("w16cid:durableId") else null + removeElement(el) + archive.commentsIdsChanged = true + } + } + val did = durableId + if (did != null) { + archive.commentsExtensibleDoc?.let { doc -> + val el = findByAttr(doc, "w16cex:commentExtensible", "w16cex:durableId", did) + if (el != null) { + removeElement(el) + archive.commentsExtensibleChanged = true + } + } + } +} + +/** Edit build (2026-09-28): replaces the ROOT ``'s own paragraph + * text in place — author/date/w:id/paraId untouched, no "edited" marker + * (decisions.json). Never touches document.xml: the anchor range doesn't + * move just because the words changed. Mirrors `mutateEditComment` + * (docx-comments.ts). */ +private fun mutateEditComment(archive: LoadedArchive, id: String, rawText: String): DocxWriteResult { + val text = stripIllegalXmlChars(rawText) + val rawId = stripWPrefix(id) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val commentEl = findByAttr(archive.commentsDoc, "w:comment", "w:id", rawId) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + replaceCommentParagraphText(archive.commentsDoc, commentEl, text) + archive.commentsChanged = true + return DocxWriteResult.Ok(text) +} + +/** Edit build: `id` names the THREAD's root (matches every other + * reply/resolve/reopen/move call shape); `replyId` (`w-{id}-r{n}`, its + * ordinal read via `replyOrdinalFromId`) selects which reply within it. + * Mirrors `mutateEditReply` (docx-comments.ts). */ +private fun mutateEditReply(archive: LoadedArchive, id: String, replyId: String, rawText: String): DocxWriteResult { + val text = stripIllegalXmlChars(rawText) + val rawRootId = stripWPrefix(id) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val ordinal = replyOrdinalFromId(replyId) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val rootParaId = findCommentParaId(archive.commentsDoc, rawRootId) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val target = collectReplyEntries(archive, rootParaId).getOrNull(ordinal - 1) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val author = target.getAttribute("w:author") + val date = target.getAttribute("w:date") + replaceCommentParagraphText(archive.commentsDoc, target, text) + archive.commentsChanged = true + return DocxWriteResult.Ok(CommentReply(id = replyId, author = toCommentAuthor(author), text = text, createdAt = parseDate(date))) +} + +/** Delete build: removes the ROOT `` AND every reply chained to + * it (`w15:paraIdParent`), their `commentsExtended.xml`/`commentsIds.xml`/ + * `commentsExtensible.xml` entries, and the root's OWN + * `w:commentRangeStart`/`End` + `w:commentReference` run from + * document.xml — a reply never has its own range (only the root gets one), + * so there is nothing to remove from document.xml for any reply. + * Decisions.json: "deleting a thread's first comment deletes the whole + * thread." Mirrors `mutateDeleteThread` (docx-comments.ts). */ +private fun mutateDeleteThread(archive: LoadedArchive, id: String): DocxWriteResult { + val rawId = stripWPrefix(id) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val rootEl = findByAttr(archive.commentsDoc, "w:comment", "w:id", rawId) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val rootP = elementsByTag(rootEl, "w:p").firstOrNull() + val rootParaId = rootP?.let { if (it.hasAttribute("w14:paraId")) it.getAttribute("w14:paraId") else null } + ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + + // Only the root has a document.xml anchor — removed here, mirroring + // mutateMoveComment's own ordering. A comment whose range was already + // dropped by a prior edit (e.g. mammoth-dropped run) simply has nothing + // to remove here; the delete still proceeds. + removeCommentRangeAndReference(archive.documentDoc, rawId) + archive.documentChanged = true + + for (replyEl in collectReplyEntries(archive, rootParaId)) { + val p = elementsByTag(replyEl, "w:p").firstOrNull() + val paraId = p?.let { if (it.hasAttribute("w14:paraId")) it.getAttribute("w14:paraId") else null } + removeElement(replyEl) + if (paraId != null) { + removeExtendedEntry(archive.extendedDoc, paraId) + removeCommentExtensionEntries(archive, paraId) + } + } + removeElement(rootEl) + removeExtendedEntry(archive.extendedDoc, rootParaId) + removeCommentExtensionEntries(archive, rootParaId) + archive.commentsChanged = true + archive.extendedChanged = true + + return DocxWriteResult.Ok(Unit) +} + +/** Delete build: removes ONE reply — its `` and its own + * extended/ids/extensible entries — never touching document.xml (a reply + * has no range of its own) or the root/other replies. Mirrors + * `mutateDeleteReply` (docx-comments.ts). */ +private fun mutateDeleteReply(archive: LoadedArchive, id: String, replyId: String): DocxWriteResult { + val rawRootId = stripWPrefix(id) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val ordinal = replyOrdinalFromId(replyId) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val rootParaId = findCommentParaId(archive.commentsDoc, rawRootId) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val target = collectReplyEntries(archive, rootParaId).getOrNull(ordinal - 1) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val p = elementsByTag(target, "w:p").firstOrNull() + val paraId = p?.let { if (it.hasAttribute("w14:paraId")) it.getAttribute("w14:paraId") else null } + removeElement(target) + archive.commentsChanged = true + if (paraId != null) { + removeExtendedEntry(archive.extendedDoc, paraId) + archive.extendedChanged = true + removeCommentExtensionEntries(archive, paraId) + } + return DocxWriteResult.Ok(Unit) +} + +private fun countAttrOccurrences(xml: String, tag: String, attr: String, value: String): Int { + val escaped = Regex.escape(value) + val re = Regex("<$tag\\b[^>]*\\b$attr=\"$escaped\"") + return re.findAll(xml).count() +} + +private fun isoNow(): String = java.time.Instant.now().toString() + +/** Every entry name this module parses/mutates as a DOM part — every OTHER + * entry in the archive is copied through verbatim, untouched. */ +private val TRACKED_PART_NAMES = setOf( + "word/document.xml", + "word/comments.xml", + "word/commentsExtended.xml", + "[Content_Types].xml", + "word/_rels/document.xml.rels", + "word/commentsIds.xml", + "word/commentsExtensible.xml", +) + +/** + * Loads the SIX tracked parts (`TRACKED_PART_NAMES`) off an ALREADY-OPEN + * `zip` (owned and closed by the caller, `loadMutateSerialize` — kept open + * across load+mutate+serialize so `serializeArchiveToFile` can stream every + * OTHER entry directly from it, see that function's own header), mirroring + * T16's own size-guard-before-decompress discipline. F16 (review 2): unlike + * the read path's fixed three-named-part check, a WRITE must copy through + * EVERY other part unchanged too, so the WHOLE archive is size-guarded up + * front (`checkAllEntriesWithinCeiling`, metadata-only, the same guard + * T18/§4.3a's xlsx reader uses for its own variable part set) before + * anything is decompressed — an over-size file routes to `ARCHIVE_TOO_LARGE`, + * never an OOM. Review F1 (android-xlsx-review): this no longer eagerly + * decompresses every non-tracked entry into a byte map — only these six. + */ +private fun loadArchiveForWrite(zip: ZipFile): DocxWriteResult { + return try { + val totalCheck = checkAllEntriesWithinCeiling(zip) + if (totalCheck is ZipSizeGuardResult.ArchiveTooLarge) { + return DocxWriteResult.Err(DocxWriteError.ARCHIVE_TOO_LARGE) + } + + val documentEntry = zip.getEntry("word/document.xml") ?: return DocxWriteResult.Err(DocxWriteError.MISSING_DOCUMENT_PART) + val contentTypesEntry = zip.getEntry("[Content_Types].xml") ?: return DocxWriteResult.Err(DocxWriteError.MISSING_DOCUMENT_PART) + + val documentXml = readEntryBounded(zip, documentEntry).toString(Charsets.UTF_8) + val contentTypesXml = readEntryBounded(zip, contentTypesEntry).toString(Charsets.UTF_8) + val commentsEntry = zip.getEntry("word/comments.xml") + val extendedEntry = zip.getEntry("word/commentsExtended.xml") + val relsEntry = zip.getEntry("word/_rels/document.xml.rels") + val commentsIdsEntry = zip.getEntry("word/commentsIds.xml") + val commentsExtensibleEntry = zip.getEntry("word/commentsExtensible.xml") + + val commentsXml = commentsEntry?.let { readEntryBounded(zip, it).toString(Charsets.UTF_8) } + val extendedXml = extendedEntry?.let { readEntryBounded(zip, it).toString(Charsets.UTF_8) } + val relsXml = relsEntry?.let { readEntryBounded(zip, it).toString(Charsets.UTF_8) } + val commentsIdsXml = commentsIdsEntry?.let { readEntryBounded(zip, it).toString(Charsets.UTF_8) } + val commentsExtensibleXml = commentsExtensibleEntry?.let { readEntryBounded(zip, it).toString(Charsets.UTF_8) } + + val archive = LoadedArchive( + zip = zip, + documentDoc = parseXml(documentXml), + documentXmlOriginal = documentXml, + commentsDoc = parseXml(commentsXml ?: EMPTY_COMMENTS_XML), + commentsIsNew = commentsXml == null, + commentsTouched = commentsXml != null, + commentsXmlOriginal = commentsXml, + extendedDoc = parseXml(extendedXml ?: EMPTY_EXTENDED_XML), + extendedIsNew = extendedXml == null, + extendedTouched = extendedXml != null, + extendedXmlOriginal = extendedXml, + contentTypesDoc = parseXml(contentTypesXml), + contentTypesXmlOriginal = contentTypesXml, + relsDoc = parseXml(relsXml ?: EMPTY_RELS_XML), + relsXmlOriginal = relsXml, + relsTouched = relsXml != null, + commentsIdsDoc = commentsIdsXml?.let { parseXml(it) }, + commentsIdsXmlOriginal = commentsIdsXml, + commentsExtensibleDoc = commentsExtensibleXml?.let { parseXml(it) }, + commentsExtensibleXmlOriginal = commentsExtensibleXml, + ) + DocxWriteResult.Ok(archive) + } catch (_: DocxUnsafeXmlDoctypeException) { + DocxWriteResult.Err(DocxWriteError.UNSAFE_XML) + } catch (_: ZipBombDetectedException) { + DocxWriteResult.Err(DocxWriteError.ARCHIVE_TOO_LARGE) + } +} + +private val XML_DECL_RE = Regex("^<\\?xml[^>]*\\?>") + +private fun serializeDocument(doc: Document): String { + val transformer = TransformerFactory.newInstance().newTransformer() + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes") + transformer.setOutputProperty(OutputKeys.METHOD, "xml") + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8") + val writer = java.io.StringWriter() + transformer.transform(DOMSource(doc), StreamResult(writer)) + return writer.toString() +} + +/** + * Mirrors desktop's `serializePart` (docx-comments.ts, F2): a CHANGED part is + * re-serialized fresh, with its XML declaration forced back to the ORIGINAL + * part's own declaration (verbatim) when one exists, or a sensible default + * when this operation created the part from nothing. Unlike desktop's + * linkedom-based serializer, `javax.xml.transform`'s own serializer does not + * add a space before a self-closing tag's `/>` — confirmed empirically while + * building this module — so no post-processing-away of that specific + * linkedom quirk is needed here (F2's OTHER half, "an untouched part is + * written back byte-for-byte verbatim", is handled by `writePart` below, + * never routing an unchanged part through this function at all). + */ +private fun serializePart(doc: Document, originalXml: String?): String { + val body = serializeDocument(doc) + val decl = originalXml?.let { XML_DECL_RE.find(it)?.value } + ?: """""" + return decl + body +} + +private fun ZipOutputStream.writeEntry(name: String, bytes: ByteArray) { + putNextEntry(ZipEntry(name)) + write(bytes) + closeEntry() +} + +// Same ceiling `DocCommentsZipSizeGuard.kt`'s own `readEntryBounded` uses, +// duplicated here (not exported from that file — `XlsxComments.kt` keeps its +// own identical copy for the same reason, "two write modules are +// deliberately independent") so a STREAMED copy of an untouched, still- +// compressed entry gets the identical real-byte-counted zip-bomb backstop a +// fully-buffered `readEntryBounded` call already gives every part this +// module actually parses. +private const val MAX_STREAMED_ENTRY_BYTES = 200L * 1024 * 1024 +private const val STREAM_COPY_BUFFER_BYTES = 8192 + +/** Review F1 (High, android-xlsx-review) fix, ported from `XlsxComments.kt`'s + * own identical helper: streams ONE untouched zip entry straight from + * `sourceZip` to `zos`, in bounded chunks, NEVER buffering its whole + * decompressed content in one array. A `STORED` entry (already + * uncompressed — no expansion possible) is passed through as RAW bytes with + * its OWN size/crc preserved exactly. Anything else (`DEFLATED`) is + * decompressed and re-compressed at the output stream's own default level, + * STREAMED through one small fixed buffer, with the same real-byte-counted + * zip-bomb backstop `readEntryBounded` gives every part this module + * actually parses. See that file's own copy for the full "no public + * java.util.zip API for a raw DEFLATE passthrough" citation. */ +private fun streamCopyUntouchedEntry(sourceZip: ZipFile, entry: ZipEntry, zos: ZipOutputStream) { + val isStored = entry.method == ZipEntry.STORED + val outEntry = ZipEntry(entry.name) + if (isStored) { + outEntry.method = ZipEntry.STORED + outEntry.size = entry.size + outEntry.compressedSize = entry.size + outEntry.crc = entry.crc + } + zos.putNextEntry(outEntry) + sourceZip.getInputStream(entry).use { input -> + val buffer = ByteArray(STREAM_COPY_BUFFER_BYTES) + var total = 0L + while (true) { + val n = input.read(buffer) + if (n == -1) break + total += n + if (!isStored && total > MAX_STREAMED_ENTRY_BYTES) throw ZipBombDetectedException() + zos.write(buffer, 0, n) + } + } + zos.closeEntry() +} + +/** Writes the mutated archive to `outFile` (a scratch path — never the live + * target, see `writeDocxMutation`). Every tracked part is written back + * byte-for-byte verbatim when this operation left it unchanged (`writePart`'s + * `!changed` branch), or freshly serialized when it didn't. Every OTHER + * entry — genuinely untouched by this write — is streamed straight through + * from `archive.zip` via `streamCopyUntouchedEntry`, never decompressed into + * a `ByteArray` here (review F1: this REPLACES the old unconditional + * `for ((name, bytes) in archive.entries)` loop, which iterated a map that + * had ALREADY fully decompressed every one of those entries at load time). + * Mirrors `serializeArchive` (docx-comments.ts). */ +private fun serializeArchiveToFile(archive: LoadedArchive, outFile: File) { + ZipOutputStream(java.io.BufferedOutputStream(java.io.FileOutputStream(outFile))).use { zos -> + fun writePart(name: String, changed: Boolean, doc: Document, originalXml: String?) { + val bytes = if (!changed && originalXml != null) { + originalXml.toByteArray(Charsets.UTF_8) + } else { + serializePart(doc, originalXml).toByteArray(Charsets.UTF_8) + } + zos.writeEntry(name, bytes) + } + writePart("word/document.xml", archive.documentChanged, archive.documentDoc, archive.documentXmlOriginal) + if (archive.commentsTouched) { + writePart("word/comments.xml", archive.commentsChanged, archive.commentsDoc, archive.commentsXmlOriginal) + } + if (archive.extendedTouched) { + writePart("word/commentsExtended.xml", archive.extendedChanged, archive.extendedDoc, archive.extendedXmlOriginal) + } + writePart("[Content_Types].xml", archive.contentTypesChanged, archive.contentTypesDoc, archive.contentTypesXmlOriginal) + if (archive.relsTouched) { + writePart("word/_rels/document.xml.rels", archive.relsChanged, archive.relsDoc, archive.relsXmlOriginal) + } + archive.commentsIdsDoc?.let { + writePart("word/commentsIds.xml", archive.commentsIdsChanged, it, archive.commentsIdsXmlOriginal) + } + archive.commentsExtensibleDoc?.let { + writePart("word/commentsExtensible.xml", archive.commentsExtensibleChanged, it, archive.commentsExtensibleXmlOriginal) + } + for (entry in java.util.Collections.list(archive.zip.entries())) { + if (entry.isDirectory) continue + if (TRACKED_PART_NAMES.contains(entry.name)) continue + streamCopyUntouchedEntry(archive.zip, entry, zos) + } + } +} + +// ----------------------------------------------------------------------- +// Pure, in-memory mutations against an already-loaded archive. +// ----------------------------------------------------------------------- + +private fun mutateAddComment(archive: LoadedArchive, selector: CommentSelector, rawText: String, author: CommentAuthor): DocxWriteResult { + if (selector !is CommentSelector.Text) return DocxWriteResult.Err(DocxWriteError.INVALID_SELECTOR) + // WHY strip rather than refuse — see `stripIllegalXmlChars`'s own doc + // comment. Still done BEFORE ever constructing a text node (the + // Transformer would otherwise throw uncaught when this archive is + // serialized), just no longer a refusal. + val text = stripIllegalXmlChars(rawText) + val walked = walkDocument(archive.documentDoc, collectLeaves = true) + val resolved = resolveSelector(walked.fullText, selector.selector) ?: return DocxWriteResult.Err(DocxWriteError.SELECTOR_NOT_FOUND) + + val newId = maxExistingCommentId(archive.commentsDoc) + 1 + val paraId = generateParaId(collectAllParaIds(archive)) + + if (archive.commentsIsNew) ensureCommentsPart(archive) + insertCommentRangeMarkers(archive.documentDoc, walked.leaves!!, resolved.start, resolved.end, newId.toString()) + archive.documentChanged = true + appendCommentEntry(archive.commentsDoc, newId, commentAuthorToDisplayName(author), isoNow(), paraId, text) + archive.commentsChanged = true + recordCommentExtensionParts(archive, paraId) + + return DocxWriteResult.Ok("w-$newId") +} + +/** + * Ordinal position (1-based) a NEW reply to `targetParaId` would get once + * written — mirrors `readDocxComments`'s own `w-${root.id}-r${list.size+1}` + * numbering (this file's read path, above) exactly, computed from the + * archive's CURRENT state (before this write's own `appendCommentEntry`/ + * `upsertExtendedEntry` calls add the new entry). T5 review parity (design + * §1.6, F2): the SAME id `replyToDocxComment`'s desktop counterpart + * (docx-comments.ts) mints for its own enriched response. + */ +private fun nextReplyOrdinal(archive: LoadedArchive, targetParaId: String): Int { + // Edit/delete build (2026-09-28): reuses `collectReplyEntries` (added for + // edit-reply/delete-reply/delete-thread) instead of a second copy of the + // same paraIdParent-chain walk — mirrors docx-comments.ts's own + // `nextReplyOrdinal`, which made the identical refactor. + return collectReplyEntries(archive, targetParaId).size + 1 +} + +/** T5 review parity (design §1.6, F2): returns the real persisted + * `CommentReply` alongside the mutated archive, mirroring desktop's own + * `mutateReplyToComment` (docx-comments.ts) — so `replyToDocxComment` below + * can enrich its response the SAME way, keeping desktop/Android response + * shapes in parity. */ +private fun mutateReplyToComment(archive: LoadedArchive, id: String, rawText: String, author: CommentAuthor): DocxWriteResult { + // WHY strip rather than refuse: same as Add — see its own comment there. + val text = stripIllegalXmlChars(rawText) + val rawId = stripWPrefix(id) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val targetParaId = findCommentParaId(archive.commentsDoc, rawId) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + + // Computed BEFORE this reply's own entry is appended below — the id + // depends on the file's CURRENT state at write time, not something the + // caller could have pre-minted. + val ordinal = nextReplyOrdinal(archive, targetParaId) + val createdAtIso = isoNow() + + val newId = maxExistingCommentId(archive.commentsDoc) + 1 + val paraId = generateParaId(collectAllParaIds(archive)) + appendCommentEntry(archive.commentsDoc, newId, commentAuthorToDisplayName(author), createdAtIso, paraId, text) + archive.commentsChanged = true + + if (archive.extendedIsNew) ensureExtendedPart(archive) + upsertExtendedEntry(archive.extendedDoc, paraId, false, targetParaId) + archive.extendedChanged = true + recordCommentExtensionParts(archive, paraId) // a reply is its own new comment + + return DocxWriteResult.Ok(CommentReply(id = "w-$rawId-r$ordinal", author = author, text = text, createdAt = parseDate(createdAtIso))) +} + +private fun mutateSetResolved(archive: LoadedArchive, id: String, done: Boolean): DocxWriteResult { + val rawId = stripWPrefix(id) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + val targetParaId = findCommentParaId(archive.commentsDoc, rawId) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + + if (archive.extendedIsNew) ensureExtendedPart(archive) + upsertExtendedEntry(archive.extendedDoc, targetParaId, done, null) + archive.extendedChanged = true + + return DocxWriteResult.Ok(Unit) +} + +private fun mutateMoveComment(archive: LoadedArchive, id: String, newSelector: CommentSelector): DocxWriteResult { + if (newSelector !is CommentSelector.Text) return DocxWriteResult.Err(DocxWriteError.INVALID_SELECTOR) + val rawId = stripWPrefix(id) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + if (findCommentParaId(archive.commentsDoc, rawId) == null) return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + if (!removeCommentRangeAndReference(archive.documentDoc, rawId)) return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + archive.documentChanged = true + + // Re-resolve against the CURRENT text (the old range is already gone from + // this in-memory copy, but nothing has been written to the real target + // yet — a SELECTOR_NOT_FOUND return below discards this whole in-memory + // archive, leaving the on-disk original untouched). + val walked = walkDocument(archive.documentDoc, collectLeaves = true) + val resolved = resolveSelector(walked.fullText, newSelector.selector) ?: return DocxWriteResult.Err(DocxWriteError.SELECTOR_NOT_FOUND) + + insertCommentRangeMarkers(archive.documentDoc, walked.leaves!!, resolved.start, resolved.end, rawId) + + return DocxWriteResult.Ok(Unit) +} + +// ----------------------------------------------------------------------- +// F17: a minimal OOXML relationship/content-types sanity check — every r:id +// referenced anywhere in document.xml resolves in word/_rels/document.xml.rels, +// and every comments/commentsExtended part actually present has a matching +// [Content_Types].xml Override. F1 (T11 review): scoped to a before/after +// DIFF of dangling ids — a reference that was ALREADY dangling before this +// write touched the file is left unblamed. Mirrors `collectDanglingRIds`/ +// `verifyOoxmlWiring` (docx-comments.ts). +// ----------------------------------------------------------------------- + +private val RID_REF_RE = Regex("r:id=\"([^\"]*)\"") + +private fun collectDanglingRIds(file: File): Set { + val zip = try { ZipFile(file) } catch (_: Exception) { return emptySet() } + return try { + zip.use { z -> + val documentEntry = z.getEntry("word/document.xml") ?: return emptySet() + val relsEntry = z.getEntry("word/_rels/document.xml.rels") + val documentXml = readEntryBounded(z, documentEntry).toString(Charsets.UTF_8) + val relsXml = relsEntry?.let { readEntryBounded(z, it).toString(Charsets.UTF_8) } + val relIds = mutableSetOf() + if (relsXml != null) { + for (el in elementsByTag(parseXml(relsXml), "Relationship")) { + val id = el.getAttribute("Id") + if (id.isNotEmpty()) relIds.add(id) + } + } + val dangling = mutableSetOf() + for (m in RID_REF_RE.findAll(documentXml)) { + val id = m.groupValues[1] + if (!relIds.contains(id)) dangling.add(id) + } + dangling + } + } catch (_: Exception) { + emptySet() + } +} + +private fun verifyOoxmlWiring(outFile: File, originalDangling: Set): Boolean { + val zip = try { ZipFile(outFile) } catch (_: Exception) { return false } + return try { + zip.use { z -> + val documentEntry = z.getEntry("word/document.xml") ?: return false + val contentTypesEntry = z.getEntry("[Content_Types].xml") ?: return false + val relsEntry = z.getEntry("word/_rels/document.xml.rels") + val documentXml = readEntryBounded(z, documentEntry).toString(Charsets.UTF_8) + val contentTypesXml = readEntryBounded(z, contentTypesEntry).toString(Charsets.UTF_8) + val relsXml = relsEntry?.let { readEntryBounded(z, it).toString(Charsets.UTF_8) } + + val relIds = mutableSetOf() + if (relsXml != null) { + for (el in elementsByTag(parseXml(relsXml), "Relationship")) { + val id = el.getAttribute("Id") + if (id.isNotEmpty()) relIds.add(id) + } + } + for (m in RID_REF_RE.findAll(documentXml)) { + val id = m.groupValues[1] + if (!relIds.contains(id) && !originalDangling.contains(id)) return false + } + + val contentTypesDoc = parseXml(contentTypesXml) + val overrides = elementsByTag(contentTypesDoc, "Override").map { it.getAttribute("PartName") }.toSet() + if (z.getEntry("word/comments.xml") != null && !overrides.contains("/word/comments.xml")) return false + if (z.getEntry("word/commentsExtended.xml") != null && !overrides.contains("/word/commentsExtended.xml")) return false + true + } + } catch (_: Exception) { + false + } +} + +// ----------------------------------------------------------------------- +// The write pipeline: backup, atomic replace, verify-before-replace. +// See this section's own top-of-file header for why verify runs against a +// scratch file BEFORE the real target is ever touched, and why only an +// in-process Mutex (not CasWrite.kt's cross-process mkdir lock) guards it. +// ----------------------------------------------------------------------- + +private val docxWriteLocks = ConcurrentHashMap() +private fun docxLockFor(absolutePath: String): Mutex = docxWriteLocks.computeIfAbsent(absolutePath) { Mutex() } + +/** Exported so a test can locate a specific write's backup file directly. */ +const val DOCX_BACKUP_SUFFIX = ".docx.bak" + +/** F1 (T17 implementation review, major/durability): the REAL fsync + * `writeDocxMutation` defaults to — `FileChannel.force(true)` against an + * already-closed-for-writing file, the SAME idiom `artifacts/CasWrite.kt`'s + * own `casWrite`/`mutateFileUnderLock` already use for exactly this reason + * (see that file's own header comment). A plain rename only reorders a + * directory entry; without this, the bytes the new name points at may still + * be sitting in a page-cache buffer the kernel hasn't flushed yet, so a + * crash right after the rename can leave the real target file truncated. */ +private fun defaultFsyncFile(file: File) { + FileChannel.open(file.toPath(), StandardOpenOption.READ, StandardOpenOption.WRITE).use { it.force(true) } +} + +/** F1: best-effort fsync of a DIRECTORY, run AFTER the atomic replace — + * durability of the rename's own directory-entry update, not the file's + * bytes (those are already covered by `defaultFsyncFile` above, which runs + * BEFORE the rename). "Where supported" per the finding: opening a + * directory as a read-only `FileChannel` and forcing it is a POSIX-only + * operation. Android's filesystem is always POSIX (ext4/f2fs), so this + * normally succeeds, but it is wrapped in its own try/catch regardless — + * this is defense in depth on top of an already-successful write, never a + * gate a real failure here should be allowed to fail the whole operation + * over. */ +private fun bestEffortFsyncDir(dir: File) { + try { + FileChannel.open(dir.toPath(), StandardOpenOption.READ).use { it.force(true) } + } catch (_: Exception) { + // Not fatal — see this function's own doc comment. The file-level + // fsync in `defaultFsyncFile` already guarantees the CONTENT survives + // a crash; this is one further layer for the rename itself. + } +} + +/** One rolling backup per file — never one per write. The filename is a hash + * of the SOURCE file's own absolute path, never a timestamp, so the next + * write to the same file overwrites the previous backup rather than + * accumulating one forever. Same directory NAME desktop's write-pipeline.ts + * uses under `~/.claude` (`youcoded-doc-backups`), rooted at THIS device's + * own `homeDir` — "the app's own storage" the task brief asks this be + * documented as. */ +fun docxBackupPathFor(homeDir: File, absolutePath: String): File { + val hash = sha256Hex(absolutePath) + val dir = File(File(homeDir, ".claude"), "youcoded-doc-backups") + return File(dir, "$hash$DOCX_BACKUP_SUFFIX") +} + +/** + * T17 follow-up (design §3.3's new step 0, design review round 3 F4 — the + * SAME check desktop's write-pipeline.ts now does, mirrored here): refuses a + * write when a real Word/Excel `~$` owner file or a LibreOffice + * `.~lock.#` lock file sits beside the target. `false` does NOT prove + * the file is currently held open — only that one of the two conventions' + * sibling file exists (a stale lock after a crash, or a program that holds + * the file open without either convention, are both accepted, named + * limitations, same as desktop's — §3.3's own residual-limitation text). + */ +internal fun isFileOpenElsewhere(target: File): Boolean { + val dir = target.parentFile ?: return false + val name = target.name + // WHY three owner-file forms (2026-09-28 PR review, mirrors + // write-pipeline.ts): Word keeps the whole name only up to 6 characters + // before the extension; at 7 it drops the first character and at 8+ the + // first two (`Report2024.docx` → `~$port2024.docx`). Excel uses the full + // name. Checking only the full form missed most real documents. + val ownerForms = linkedSetOf(name, name.drop(1), name.drop(2)).filter { it.isNotEmpty() } + val lockFile = File(dir, ".~lock.$name#") + return ownerForms.any { File(dir, "~\$$it").exists() } || lockFile.exists() +} + +/** + * The generic half of the pipeline: lock, scratch-copy, hand `workCopy`/ + * `outFile` to `mutate` (format-specific logic lives entirely in the + * caller's closure — this function never parses OOXML itself), verify, + * backup, atomic replace. `mutate`/`verify` operate on FILES (never a + * pre-parsed archive) so this shares the exact same bytes-in/file-out shape + * as desktop's `writeFileMutation` (write-pipeline.ts) and, not coincidentally, + * so a test can inject a fault the same simple way desktop's own T11 pinning + * test does (read `workCopy`, write deliberately-corrupted bytes to `outFile`, + * return `true` from `mutate`, `false` from `verify`) without needing to + * fabricate a `LoadedArchive` by hand. `internal` (not `private`): the T17 + * pinning test for "failed verification leaves the target byte-identical" + * calls this directly, the same way docx-comments.test.ts calls the exported + * `writeDocxMutation` on the TS side. + */ +internal suspend fun writeDocxMutation( + absolutePath: String, + homeDir: File, + mutate: (workCopy: File, outFile: File) -> DocxWriteResult, + verify: (outFile: File, value: T, originalFile: File) -> Boolean, + // F1 (T17 implementation review, major/durability): a seam, not a + // hardcoded call — defaults to the REAL fsync (`defaultFsyncFile` below, + // same `FileChannel.force(true)` idiom as artifacts/CasWrite.kt's own + // atomic write). A test proves "the sync call happens before the rename" + // by injecting a FAILING fsync here and checking the target/backup were + // never touched — a JVM unit test has no other way to observe fsync + // ordering, since fsync's only real effect is durability across a crash + // this process never experiences. + syncFile: (File) -> Unit = ::defaultFsyncFile, +): DocxWriteResult { + return docxLockFor(absolutePath).withLock { + val target = File(absolutePath) + // Step 0 (design §3.3, review round 3 F4) — the VERY FIRST thing this + // pipeline does, before backup (step 1) or even the read below. + if (isFileOpenElsewhere(target)) return@withLock DocxWriteResult.Err(DocxWriteError.FILE_OPEN_ELSEWHERE) + if (!target.exists()) return@withLock DocxWriteResult.Err(DocxWriteError.READ_FAILED) + val parentDir = target.parentFile ?: return@withLock DocxWriteResult.Err(DocxWriteError.READ_FAILED) + // Sibling scratch paths (never the system temp dir) — maximizes the + // chance `Files.move` below is a true same-filesystem ATOMIC_MOVE, + // mirroring desktop's own `${absolutePath}.${pid}.${now}.tmp` sibling + // convention (write-pipeline.ts) and CasWrite.kt's `.tmp`. + // A fixed name (no pid/timestamp) is safe here because the + // per-absolute-path Mutex above already excludes every other + // in-process writer of THIS same file. + val workCopy = File(parentDir, "${target.name}.ycdread.tmp") + val outFile = File(parentDir, "${target.name}.ycdwrite.tmp") + try { + try { + target.copyTo(workCopy, overwrite = true) + } catch (_: Exception) { + return@withLock DocxWriteResult.Err(DocxWriteError.READ_FAILED) + } + + val mutated = mutate(workCopy, outFile) + if (mutated is DocxWriteResult.Err) return@withLock mutated + val value = (mutated as DocxWriteResult.Ok).value + + // Verify-by-reread BEFORE the real target is ever touched — see + // this section's own header for why a failure here needs no + // separate "restore the backup" step: `target` was never written. + // xmlPartsStayWellFormed: the strict namespace check the lenient DOM + // can't do (DocCommentsXmlSafety.kt, 2026-09-28 PR review). + val verified = try { xmlPartsStayWellFormed(outFile, target) && verify(outFile, value, target) } catch (_: Exception) { false } + if (!verified) return@withLock DocxWriteResult.Err(DocxWriteError.VERIFY_FAILED) + + // F1 (T17 implementation review, major/durability): fsync the + // ALREADY-VERIFIED output's bytes BEFORE it ever replaces the + // real target, or even before the backup is taken. A bare + // `Files.move` only reorders a directory entry — it says nothing + // about whether the bytes the new name will point at are durable + // yet, so a crash between the rename returning and the OS's own + // lazy flush could leave the REAL document truncated (the + // failure mode this finding names). Ordered before the backup + // copy too, on purpose: a fsync fault must leave the target + // exactly as untouched as a verify fault does, never a state + // where a backup was written but the sync it depended on never + // happened. + try { + syncFile(outFile) + } catch (_: Exception) { + return@withLock DocxWriteResult.Err(DocxWriteError.WRITE_FAILED) + } + + // Step 1 (backup, §3.3): capture the pre-mutation bytes at a + // stable, rolling, per-file path OUTSIDE any project, kept as a + // standing safety net after success (never deleted). + val backupFile = docxBackupPathFor(homeDir, absolutePath) + try { + backupFile.parentFile?.mkdirs() + target.copyTo(backupFile, overwrite = true) + } catch (_: Exception) { + return@withLock DocxWriteResult.Err(DocxWriteError.BACKUP_FAILED) + } + + // Atomic replace: the ALREADY-VERIFIED, ALREADY-FSYNCED output + // takes the target's place — never a direct overwrite. Mirrors + // CasWrite.kt's own ATOMIC_MOVE-with-REPLACE_EXISTING-fallback + // shape. + try { + try { + Files.move( + outFile.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: java.nio.file.AtomicMoveNotSupportedException) { + Files.move(outFile.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } catch (_: Exception) { + return@withLock DocxWriteResult.Err(DocxWriteError.WRITE_FAILED) + } + // F1: best-effort fsync of the PARENT DIRECTORY too, so the + // rename's own directory-entry update is itself durable, not + // just the file's bytes — "where supported" per the finding: + // opening a directory as a FileChannel is a POSIX operation + // (always available on Android's ext4/f2fs), wrapped in its own + // try/catch so a filesystem/JVM that refuses it never fails a + // write that has already fully succeeded. + bestEffortFsyncDir(parentDir) + DocxWriteResult.Ok(value) + } finally { + workCopy.delete() + outFile.delete() // no-op once already moved onto `target` + } + } +} + +/** Glues the generic file-in/file-out pipeline above to the archive-based + * mutations below: opens `workCopy` as a `ZipFile` ONCE and keeps it open + * across load, mutate, AND serialize (`finally { zip.close() }`) — the + * structural change review F1 needed, since `serializeArchiveToFile` must be + * able to stream-copy an untouched entry straight from the SAME open source + * archive `loadArchiveForWrite` read the six tracked parts from. Every real + * operation's own `mutate` closure is just this plus its own archive + * mutation function. A `ZipBombDetectedException` thrown during serialize + * (the streamed backstop firing on an untouched entry) is mapped directly to + * `ARCHIVE_TOO_LARGE` here rather than falling into the generic + * `WRITE_FAILED` catch below it — the same "never swallow a typed signal + * into a wrong wire code" discipline `XlsxComments.kt`'s own equivalent fix + * uses (there via re-throw past an extra layer this file doesn't have). */ +private fun loadMutateSerialize(workCopy: File, outFile: File, mutateArchive: (LoadedArchive) -> DocxWriteResult): DocxWriteResult { + val zip = try { + ZipFile(workCopy) + } catch (_: Exception) { + return DocxWriteResult.Err(DocxWriteError.INVALID_DOCX) + } + return try { + val loaded = loadArchiveForWrite(zip) + if (loaded is DocxWriteResult.Err) return loaded + val archive = (loaded as DocxWriteResult.Ok).value + val mutated = mutateArchive(archive) + if (mutated is DocxWriteResult.Err) return mutated + val value = (mutated as DocxWriteResult.Ok).value + try { + serializeArchiveToFile(archive, outFile) + DocxWriteResult.Ok(value) + } catch (_: ZipBombDetectedException) { + DocxWriteResult.Err(DocxWriteError.ARCHIVE_TOO_LARGE) + } catch (_: Exception) { + DocxWriteResult.Err(DocxWriteError.WRITE_FAILED) + } + } finally { + zip.close() + } +} + +// ----------------------------------------------------------------------- +// Public orchestration — one per operation, each wiring its own mutate + +// verify into `writeDocxMutation`. `absolutePath` is the already- +// containment-verified real file path (DocCommentsDispatch.kt resolves it, +// the same way it already does for `listNativeComments`); `path` is the +// caller's project-relative (or fallback-absolute) path, stamped onto +// `PersistedComment.path` — needed here only to re-run this file's own +// reader during verification. +// ----------------------------------------------------------------------- + +suspend fun addDocxComment( + absolutePath: String, + path: String, + selector: CommentSelector, + text: String, + author: CommentAuthor, + homeDir: File, +): DocxWriteResult = writeDocxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerialize(workCopy, outFile) { archive -> mutateAddComment(archive, selector, text, author) } }, + verify = { outFile, newId, originalFile -> + verifyOoxmlWiring(outFile, collectDanglingRIds(originalFile)) && + run { + val r = readDocxComments(outFile, path) + // WHY `stripIllegalXmlChars(text)`, not the raw `text` + // argument: `mutateAddComment` strips before writing, so + // comparing against the raw value would fail this check (and + // roll back a perfectly good write) for any text that + // legitimately had something stripped from it. + r is DocxReadResult.Ok && r.comments.any { it.id == newId && it.text == stripIllegalXmlChars(text) } + } + }, +) + +/** T5 review parity (design §1.6, F2): returns the real persisted + * `CommentReply` — the SAME enrichment desktop's own `replyToDocxComment` + * (docx-comments.ts) now returns, keeping desktop/remote/Android response + * shapes in parity. */ +suspend fun replyToDocxComment( + absolutePath: String, + path: String, + id: String, + text: String, + author: CommentAuthor, + homeDir: File, +): DocxWriteResult = writeDocxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerialize(workCopy, outFile) { archive -> mutateReplyToComment(archive, id, text, author) } }, + verify = { outFile, reply, originalFile -> + verifyOoxmlWiring(outFile, collectDanglingRIds(originalFile)) && + run { + val r = readDocxComments(outFile, path) + // WHY `stripIllegalXmlChars(text)` — see `addDocxComment`'s + // own comment on the same swap. + (r as? DocxReadResult.Ok)?.comments?.find { it.id == id }?.replies?.any { it.id == reply.id && it.text == stripIllegalXmlChars(text) } == true + } + }, +) + +/** `by` is accepted for call-site symmetry with the generic `{path, id, by}` + * payload but deliberately UNUSED — a native Word comment has nowhere to + * record it (§3.2: history starts empty for a freshly-read native comment; + * only the CURRENT `w15:done` bit exists in the file). Mirrors + * `resolveDocxComment` (docx-comments.ts). */ +suspend fun resolveDocxComment(absolutePath: String, path: String, id: String, homeDir: File): DocxWriteResult = writeDocxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerialize(workCopy, outFile) { archive -> mutateSetResolved(archive, id, true) } }, + verify = { outFile, _, originalFile -> + verifyOoxmlWiring(outFile, collectDanglingRIds(originalFile)) && + run { + val r = readDocxComments(outFile, path) + (r as? DocxReadResult.Ok)?.comments?.find { it.id == id }?.resolved == true + } + }, +) + +suspend fun reopenDocxComment(absolutePath: String, path: String, id: String, homeDir: File): DocxWriteResult = writeDocxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerialize(workCopy, outFile) { archive -> mutateSetResolved(archive, id, false) } }, + verify = { outFile, _, originalFile -> + verifyOoxmlWiring(outFile, collectDanglingRIds(originalFile)) && + run { + val r = readDocxComments(outFile, path) + (r as? DocxReadResult.Ok)?.comments?.find { it.id == id }?.resolved == false + } + }, +) + +suspend fun moveDocxComment( + absolutePath: String, + path: String, + id: String, + newSelector: CommentSelector, + homeDir: File, +): DocxWriteResult { + if (newSelector !is CommentSelector.Text) return DocxWriteResult.Err(DocxWriteError.INVALID_SELECTOR) + val rawId = stripWPrefix(id) ?: return DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND) + return writeDocxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerialize(workCopy, outFile) { archive -> mutateMoveComment(archive, id, newSelector) } }, + verify = { outFile, _, originalFile -> + val wiringOk = verifyOoxmlWiring(outFile, collectDanglingRIds(originalFile)) + // "The OLD range is gone, a NEW one exists" (review 3, F2) — a + // low-level marker COUNT, since PersistedComment (one record per + // comments.xml id) can't itself distinguish "exactly one range" + // from "two ranges silently collapsed by a map". + val marksOk = wiringOk && run { + val zip = try { ZipFile(outFile) } catch (_: Exception) { null } + if (zip == null) { + false + } else { + zip.use { z -> + val documentEntry = z.getEntry("word/document.xml") + if (documentEntry == null) { + false + } else { + val documentXml = readEntryBounded(z, documentEntry).toString(Charsets.UTF_8) + countAttrOccurrences(documentXml, "w:commentRangeStart", "w:id", rawId) == 1 && + countAttrOccurrences(documentXml, "w:commentRangeEnd", "w:id", rawId) == 1 && + countAttrOccurrences(documentXml, "w:commentReference", "w:id", rawId) == 1 + } + } + } + } + marksOk && run { + val r = readDocxComments(outFile, path) + val target = (r as? DocxReadResult.Ok)?.comments?.find { it.id == id } + val sel = target?.selector + sel is CommentSelector.Text && sel.selector.exact == newSelector.selector.exact + } + }, + ) +} + +// ----------------------------------------------------------------------- +// Edit/delete build (2026-09-28, design doc §"Edit and delete"). Same +// write-pipeline/verify shape as every mutation above. Mirrors +// docx-comments.ts's own "Edit/delete build" public-orchestration section. +// ----------------------------------------------------------------------- + +suspend fun editDocxComment( + absolutePath: String, + path: String, + id: String, + text: String, + homeDir: File, +): DocxWriteResult = writeDocxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerialize(workCopy, outFile) { archive -> mutateEditComment(archive, id, text) } }, + verify = { outFile, extraText, originalFile -> + verifyOoxmlWiring(outFile, collectDanglingRIds(originalFile)) && + run { + val r = readDocxComments(outFile, path) + (r as? DocxReadResult.Ok)?.comments?.find { it.id == id }?.text == extraText + } + }, +) + +suspend fun editDocxReply( + absolutePath: String, + path: String, + id: String, + replyId: String, + text: String, + homeDir: File, +): DocxWriteResult = writeDocxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerialize(workCopy, outFile) { archive -> mutateEditReply(archive, id, replyId, text) } }, + verify = { outFile, reply, originalFile -> + verifyOoxmlWiring(outFile, collectDanglingRIds(originalFile)) && + run { + val r = readDocxComments(outFile, path) + (r as? DocxReadResult.Ok)?.comments?.find { it.id == id }?.replies?.any { it.id == reply.id && it.text == reply.text } == true + } + }, +) + +suspend fun deleteDocxComment( + absolutePath: String, + path: String, + id: String, + homeDir: File, +): DocxWriteResult = writeDocxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerialize(workCopy, outFile) { archive -> mutateDeleteThread(archive, id) } }, + verify = { outFile, _, originalFile -> + val wiringOk = verifyOoxmlWiring(outFile, collectDanglingRIds(originalFile)) + val rawId = stripWPrefix(id) + // "No dangling range markers remain" — a low-level marker COUNT, the + // same structural check Move's own verify uses for "the old range is + // gone," here requiring a count of ZERO rather than exactly one. + val marksOk = wiringOk && rawId != null && run { + val zip = try { ZipFile(outFile) } catch (_: Exception) { null } + if (zip == null) { + false + } else { + zip.use { z -> + val documentEntry = z.getEntry("word/document.xml") + if (documentEntry == null) { + true + } else { + val documentXml = readEntryBounded(z, documentEntry).toString(Charsets.UTF_8) + countAttrOccurrences(documentXml, "w:commentRangeStart", "w:id", rawId) == 0 && + countAttrOccurrences(documentXml, "w:commentRangeEnd", "w:id", rawId) == 0 && + countAttrOccurrences(documentXml, "w:commentReference", "w:id", rawId) == 0 + } + } + } + } + marksOk && run { + val r = readDocxComments(outFile, path) + (r as? DocxReadResult.Ok)?.comments?.none { it.id == id } == true + } + }, +) + +suspend fun deleteDocxReply( + absolutePath: String, + path: String, + id: String, + replyId: String, + homeDir: File, +): DocxWriteResult = writeDocxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerialize(workCopy, outFile) { archive -> mutateDeleteReply(archive, id, replyId) } }, + verify = { outFile, _, originalFile -> + verifyOoxmlWiring(outFile, collectDanglingRIds(originalFile)) && + run { + val r = readDocxComments(outFile, path) + val target = (r as? DocxReadResult.Ok)?.comments?.find { it.id == id } + target != null && target.replies.none { it.id == replyId } + } + }, +) diff --git a/app/src/main/kotlin/com/youcoded/app/doccomments/XlsxComments.kt b/app/src/main/kotlin/com/youcoded/app/doccomments/XlsxComments.kt new file mode 100644 index 000000000..87ed3e70b --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/doccomments/XlsxComments.kt @@ -0,0 +1,2325 @@ +// Excel (.xlsx) comment reading + writing on Android — T18 (read) and T19 +// (write) of the doc-comments build (docs/active/specs/2026-09-26-doc- +// comments-build-design.md §4.3a, §8 T18/T19). REWRITTEN 2026-09-27 for +// Destin's threaded-comments-only decision (chat, 2026-09-27: "Excel +// comments use ONLY modern threaded comments... never old-style notes as the +// product format") — this REPLACES the entire prior legacy-Notes reader this +// file used to hold (the `stripResolvedMarker`/`splitTurns`/ +// `APP_AUTHOR_PREFIX_RE` transcript-convention heuristics), the same way +// desktop's own `xlsx-comments.ts` was rewritten in the same session +// (commit afebe3df5 onward). Old-style Notes already in a file are never +// read, created, or edited by this module — a genuine Note round-trips +// byte-for-byte untouched (writes never re-serialize a part they didn't +// touch) and is never surfaced in the comments pane (§4.1). +// +// This is a Kotlin PORT of desktop's `desktop/src/main/doc-comments/ +// xlsx-comments.ts` (T12/T13), field-for-field and error-code-for-error-code +// — see that file's own comments for the full [MS-XLSX]/real-file research +// citation (§4.2) this algorithm is built from. Two real, redistributable +// reference files this redesign's own research captured — +// `shared-fixtures/doc-comments/xlsx-threaded-reference/{docling-xlsx- +// comments,elden-ring-completionist-checklist}.xlsx` — are what T18's own +// read tests check against, copied into `app/src/test/resources/doc- +// comments/` for this module's JVM unit tests. +// +// WHY Kotlin has no `exceljs`-equivalent to lean on, and why this hand-rolls +// the same JSZip-equivalent (`java.util.zip`) + DOM (`javax.xml.parsers`) +// approach desktop's own rewrite uses: `exceljs`'s `cell.note` getter cannot +// distinguish a genuine Note from a threaded comment's own legacy- +// compatibility placeholder (§4.1) — an independently-confirmed defect in +// the retired reader this file used to hold. Neither platform's library +// ecosystem has a threaded-comments API of any kind, so both hand-roll the +// OOXML from the same researched shape. +// +// KOTLIN-SPECIFIC DEVIATIONS FROM THE TS SOURCE, EACH NAMED (mirrors +// DocxComments.kt's own precedent for naming where a "field-for-field port" +// isn't literal): +// - `Element.getAttribute()` returns `""` for an absent attribute where the +// TS/DOM `getAttribute()` returns `null` — every attribute read here that +// the TS source checks with `!== null` is instead checked with +// `.isEmpty()` (or `.isNotEmpty()`), exactly the same adaptation +// `DocxComments.kt`'s own header already documents. +// - `javax.xml.parsers` (namespace-UNAWARE, matching `DocxComments.kt`'s own +// configuration) decodes entities correctly on both `getAttribute()` and +// `.textContent` — unlike `linkedom`'s own `getAttribute()`, which desktop's +// `decodeXmlEntities` exists to work around (§4.2's own finding). No +// equivalent helper is needed here. +// - Whole-archive size guard: Kotlin's `DocCommentsZipSizeGuard.kt` already +// documents (`checkAllEntriesWithinCeiling`'s own header) that THIS module +// uses a whole-archive pre-scan rather than desktop's narrower named-parts +// check, because an xlsx's part set/count varies per workbook — this +// predates this rewrite and is unchanged by it. +// - Illegal XML 1.0 control characters (comment/reply text): this build +// STRIPS them (keeping tab/LF/CR), rather than refusing the write outright +// — see `stripIllegalXmlChars`'s own doc comment for why this diverges +// from the (superseded-in-this-same-session) refuse-based fix the T12/T13 +// adversarial review originally proposed. +// - Attribute serialization order and exact XML-declaration bytes are NOT +// pinned to match desktop's own linkedom output byte-for-byte — T21's own +// cross-platform parity guard (§9.3) proves "Android reads desktop's +// golden bytes, and desktop reads Android's own written bytes, producing +// the identical `PersistedComment[]` shape," never literal byte identity +// between the two writers' own output, so this is not a correctness gap. +package com.youcoded.app.doccomments + +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.StringReader +import java.util.Collections +import java.util.Date +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream +import javax.xml.parsers.DocumentBuilder +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException +import org.xml.sax.InputSource +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +// ============================================================================= +// Read errors/result — mirrors desktop's `XlsxReadError`/`XlsxReadResult`. +// ============================================================================= + +/** `UNSAFE_XML` has no desktop counterpart (Android-only — see + * `rejectDoctype`'s own doc comment, carried over unchanged from this file's + * prior legacy-Notes version, for why this platform needs a check desktop's + * `linkedom`-based parser doesn't). `TOO_MANY_COMMENTS` mirrors design + * review 1, F3's record-count ceiling. */ +enum class XlsxReadError { + INVALID_XLSX, + ARCHIVE_TOO_LARGE, + TOO_MANY_COMMENTS, + UNSAFE_XML, +} + +sealed class XlsxReadResult { + data class Ok(val comments: List) : XlsxReadResult() + data class Err(val error: XlsxReadError) : XlsxReadResult() +} + +/** Task-time starting point, matching desktop's own `MAX_COMMENT_RECORDS` + * precedent (xlsx-comments.ts) — not a benchmarked constant. */ +private const val MAX_COMMENT_RECORDS = 20000 + +/** Review F3 (Medium), ported: bounds the AGGREGATE bytes the full-workbook + * fallback id-resolution scan freshly parses across every sheet it visits + * within one write call — the routine path after any Move, since a moved + * thread's own embedded-cell hint goes stale the moment it moves. */ +private const val MAX_FALLBACK_SCAN_BYTES = 50L * 1024 * 1024 + +// ============================================================================= +// OOXML constants — §4.2. +// ============================================================================= + +private const val SML_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +private const val XR_NS = "http://schemas.microsoft.com/office/spreadsheetml/2014/revision" +private const val THREADED_COMMENTS_NS = "http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments" +private const val OFFICE_DOCUMENT_R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + +private const val COMMENTS_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml" +// §4.2: NO `+xml` suffix, unlike every other XML part's content type here. +private const val VML_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.vmlDrawing" +private const val THREADED_COMMENT_CONTENT_TYPE = "application/vnd.ms-excel.threadedcomments+xml" +private const val PERSON_CONTENT_TYPE = "application/vnd.ms-excel.person+xml" + +private const val COMMENTS_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" +private const val VML_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" +private const val THREADED_COMMENT_REL_TYPE = "http://schemas.microsoft.com/office/2017/10/relationships/threadedComment" +private const val PERSON_REL_TYPE = "http://schemas.microsoft.com/office/2017/10/relationships/person" + +private val EMPTY_VML_XML = + "\n" + + "" + + "" + + "" +private val EMPTY_RELS_XML = "\n" + + "" +private val EMPTY_COMMENTS_XML = "\n" + + "" +private val EMPTY_THREADED_COMMENTS_XML = "\n" + + "" +private val EMPTY_PERSON_XML = "\n" + + "" + +/** §4.2, byte-for-byte from the real-Excel sample (matches the real-Excel + * layout, not Google Sheets' own tab-indented variant — the more common of + * the two observed shapes, corroborated a third time by an independent bug + * report). */ +private val PLACEHOLDER_HEADER = "[Threaded comment]\n\n" + + "Your version of Excel allows you to read this threaded comment; however, any edits to it will get removed if the file is opened in a newer version of Excel. Learn more: https://go.microsoft.com/fwlink/?linkid=870924\n\n" + + "Comment:\n " + +private fun buildPlaceholderBody(commentText: String, replyTexts: List): String { + val sb = StringBuilder(PLACEHOLDER_HEADER).append(commentText) + for (reply in replyTexts) sb.append("\nReply:\n ").append(reply) + return sb.toString() +} + +private val TC_AUTHOR_RE = Regex("^tc=\\{[0-9A-Fa-f-]{36}\\}$") +private fun isTcAuthorText(text: String): Boolean = TC_AUTHOR_RE.matches(text) + +/** §4.2: strips braces and lowercases, so an uppercase (Windows/Mac Excel) + * and a lowercase (Google Sheets) GUID compare equal. This app's own writer + * always MINTS uppercase; every COMPARISON goes through this function. */ +private fun normalizeGuid(raw: String): String = raw.replace("{", "").replace("}", "").lowercase() + +private fun mintGuid(): String = "{${UUID.randomUUID().toString().uppercase()}}" + +/** §4.2's confirmed real-world format: `YYYY-MM-DDTHH:MM:SS.ff` — exactly two + * fractional-second digits, no timezone offset, never a trailing `Z`. + * Written in LOCAL time, matching every real writer sampled. */ +private fun formatThreadedDate(d: Date): String { + val cal = java.util.Calendar.getInstance() + cal.time = d + val y = cal.get(java.util.Calendar.YEAR) + val mo = cal.get(java.util.Calendar.MONTH) + 1 + val day = cal.get(java.util.Calendar.DAY_OF_MONTH) + val h = cal.get(java.util.Calendar.HOUR_OF_DAY) + val mi = cal.get(java.util.Calendar.MINUTE) + val s = cal.get(java.util.Calendar.SECOND) + val cs = cal.get(java.util.Calendar.MILLISECOND) / 10 + return "%04d-%02d-%02dT%02d:%02d:%02d.%02d".format(y, mo, day, h, mi, s, cs) +} + +private val THREADED_DATE_RE = Regex("^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})\\.(\\d+)$") + +/** Mirrors desktop's `Date.parse(dT)` (which treats an offset-less ISO-shaped + * string as LOCAL time) — a small hand-rolled parser since Java has no + * single built-in formatter for a variable-length fractional-second, no- + * timezone timestamp. Falls back to "now" for a malformed value, same as + * desktop's own `Number.isNaN(t) ? Date.now() : t`. */ +private fun parseThreadedDate(dT: String): Long { + val m = THREADED_DATE_RE.find(dT) ?: return System.currentTimeMillis() + return try { + val (y, mo, day, h, mi, s, frac) = m.destructured + val cal = java.util.Calendar.getInstance() + cal.clear() + cal.set(y.toInt(), mo.toInt() - 1, day.toInt(), h.toInt(), mi.toInt(), s.toInt()) + val fracDigits = frac.length + val millis = (frac.toInt() * Math.pow(10.0, (3 - fracDigits).toDouble())).toInt().coerceIn(0, 999) + cal.set(java.util.Calendar.MILLISECOND, millis) + cal.timeInMillis + } catch (_: Exception) { + System.currentTimeMillis() + } +} + +private fun toCommentAuthor(name: String): CommentAuthor = "person:" + name.ifEmpty { "Unknown" } + +/** §3.4/§4.1's rule, mirrored from `DocxComments.kt`'s own + * `commentAuthorToDisplayName` (duplicated rather than shared — the two + * write modules are deliberately independent, same convention desktop's own + * xlsx-comments.ts documents for its relationship to docx-comments.ts). */ +private fun commentAuthorToDisplayName(author: CommentAuthor): String = when { + author == "user" -> "You" + author == "assistant" -> "Assistant" + author.startsWith("person:") -> author.removePrefix("person:").ifEmpty { "Unknown" } + else -> "Unknown" +} + +// A1-style reference, 1-3 letters then 1-7 digits. +private val CELL_ADDRESS_RE = Regex("^[A-Z]{1,3}[1-9][0-9]{0,6}$") +private fun isValidCellAddress(addr: String): Boolean = CELL_ADDRESS_RE.matches(addr) + +private fun colLettersToNumber(letters: String): Int { + var col = 0 + for (ch in letters) col = col * 26 + (ch.code - 64) + return col +} + +/** `"B5"` -> `(col=2, row=5)`, both 1-based. `null` for a malformed address. */ +private fun parseCellRef(addr: String): Pair? { + val m = Regex("^([A-Z]{1,3})([1-9][0-9]{0,6})$").find(addr) ?: return null + return colLettersToNumber(m.groupValues[1]) to m.groupValues[2].toInt() +} + +// ----------------------------------------------------------------------- +// The app-level thread id — §4.2's GUID-embedding scheme (design review 1, +// F1; parse regex pre-written by design review round 2, F2). SAME regex as +// desktop's own `XLSX_THREAD_ID_RE`, checked against the SAME shared +// `shared-fixtures/doc-comments/id-parse-test-vectors.json`. +// ----------------------------------------------------------------------- + +// `internal`, not `private` — this class is the return type of the +// `internal` `parseXlsxThreadId` below; a pinning test (XlsxCommentsTest.kt) +// calls that function directly against the shared `id-parse-test-vectors.json` +// fixture (design review round 2, F2), so both need matching visibility. +internal class ParsedThreadId(val sheetId: Int, val cell: String, val guid: String) + +private val XLSX_THREAD_ID_RE = Regex("^xt-(\\d+)-([^-]+)-(.+)$") + +internal fun parseXlsxThreadId(id: String): ParsedThreadId? { + val m = XLSX_THREAD_ID_RE.find(id) ?: return null + val sheetId = m.groupValues[1].toIntOrNull() ?: return null + val cell = m.groupValues[2] + if (!isValidCellAddress(cell)) return null + return ParsedThreadId(sheetId, cell, m.groupValues[3]) +} + +private fun buildXlsxThreadId(sheetId: Int, cell: String, guidBraced: String): String = + "xt-$sheetId-$cell-${guidBraced.replace("{", "").replace("}", "")}" + +// ----------------------------------------------------------------------- +// Illegal XML 1.0 control characters — STRIPPED, not refused (2026-09-27+ +// direction, coordinated with the desktop/DocxComments.kt fix landing in this +// same session). XML 1.0 forbids U+0000-U+0008, U+000B-U+000C, U+000E-U+001F +// anywhere in a well-formed document, even as a numeric character reference +// (tab/LF/CR are the only C0 codepoints allowed). A prior review +// (docs/active/reviews/2026-09-27-doc-comments-xlsx-t12-t13-review.md, F1) +// chose to REFUSE this outright; the coordinating session for this build +// moved the whole feature (desktop's xlsx-comments.ts/docx-comments.ts AND +// DocxComments.kt) to silently STRIP instead — an invisible control byte from +// a bad paste/clipboard/PDF-copy is never something a user consciously typed, +// so normalizing it costs less than a hard refusal on an otherwise-fine +// comment. Applied to every user/assistant-authored string this module writes +// into `` or the legacy placeholder body. +// ----------------------------------------------------------------------- +private val ILLEGAL_XML_CHAR_REGEX = Regex("[\u0000-\u0008\u000B\u000C\u000E-\u001F]") +private fun stripIllegalXmlChars(text: String): String = ILLEGAL_XML_CHAR_REGEX.replace(text, "") + +// ----------------------------------------------------------------------- +// Path helpers — OOXML relationship `Target`s are relative to the REFERRING +// part's own directory, never absolute. +// ----------------------------------------------------------------------- + +private fun dirnameOfPart(p: String): String { + val i = p.lastIndexOf('/') + return if (i == -1) "" else p.substring(0, i) +} + +private fun basenameOfPart(p: String): String { + val i = p.lastIndexOf('/') + return if (i == -1) p else p.substring(i + 1) +} + +private fun resolveRelTarget(baseDir: String, target: String): String { + val segments = (baseDir.split("/") + target.split("/")).filter { it.isNotEmpty() } + val out = mutableListOf() + for (seg in segments) { + when (seg) { + "." -> {} + ".." -> if (out.isNotEmpty()) out.removeAt(out.size - 1) + else -> out.add(seg) + } + } + return out.joinToString("/") +} + +private fun relativizeTarget(fromDir: String, toPath: String): String { + val fromParts = fromDir.split("/").filter { it.isNotEmpty() } + val toParts = toPath.split("/").filter { it.isNotEmpty() } + var i = 0 + while (i < fromParts.size && i < toParts.size - 1 && fromParts[i] == toParts[i]) i++ + val ups = fromParts.size - i + val downs = toParts.drop(i) + return (List(ups) { ".." } + downs).joinToString("/") +} + +private fun worksheetRelsPathFor(partPath: String): String = + "${dirnameOfPart(partPath)}/_rels/${basenameOfPart(partPath)}.rels" + +// ----------------------------------------------------------------------- +// XML plumbing — namespace-UNAWARE `javax.xml.parsers` (same convention as +// `DocxComments.kt`), plus a `localName`-based walk for the ONE place +// namespace prefixes vary by writer (threadedComment/text/person — §4.2's +// own load-bearing finding: real Excel uses the DEFAULT namespace, real +// Google Sheets prefixes every element `x18tc:`). +// ----------------------------------------------------------------------- + +/** Same secure factory as `DocxComments.kt` — see that file's own header for + * the full OWASP/Android-`javax.xml`-surface reasoning. Duplicated rather + * than shared (independent-modules convention, this file's own header). */ +private fun newSecureDocumentBuilder(): DocumentBuilder { + val factory = DocumentBuilderFactory.newInstance() + factory.isNamespaceAware = false + factory.isXIncludeAware = false + factory.isExpandEntityReferences = false + val secureFeatures = listOf( + "http://apache.org/xml/features/disallow-doctype-decl" to true, + "http://xml.org/sax/features/external-general-entities" to false, + "http://xml.org/sax/features/external-parameter-entities" to false, + "http://apache.org/xml/features/nonvalidating/load-external-dtd" to false, + ) + for ((feature, value) in secureFeatures) { + try { + factory.setFeature(feature, value) + } catch (_: ParserConfigurationException) { + // Not fatal — see DocxComments.kt's identical comment. + } + } + return factory.newDocumentBuilder() +} + +/** Thrown by `parseXml` when a part's raw XML text contains a ` { + val nodes = doc.getElementsByTagName(tag) + return (0 until nodes.length).map { nodes.item(it) as Element } +} + +private fun elementsByTag(el: Element, tag: String): List { + val nodes = el.getElementsByTagName(tag) + return (0 until nodes.length).map { nodes.item(it) as Element } +} + +private fun localName(tag: String): String { + val i = tag.lastIndexOf(':') + return if (i == -1) tag else tag.substring(i + 1) +} + +private fun collectByLocalName(el: Element, name: String, out: MutableList) { + val children = el.childNodes + for (i in 0 until children.length) { + val c = children.item(i) + if (c is Element) { + if (localName(c.tagName) == name) out.add(c) + collectByLocalName(c, name, out) + } + } +} + +/** §4.2's namespace-prefix finding: matches every element by LOCAL name + * (the substring after the last `:`), never a literal (possibly `x18tc:`- + * prefixed) tag string — the Kotlin equivalent of desktop's own + * `elementsByLocalName` (xlsx-comments.ts). Mirrors that function's own + * document-order guarantee (a plain recursive descendant walk, same order a + * `querySelectorAll('*')` scan produces). */ +private fun elementsByLocalName(doc: Document, name: String): List { + val out = mutableListOf() + val root = doc.documentElement ?: return out + if (localName(root.tagName) == name) out.add(root) + collectByLocalName(root, name, out) + return out +} + +private fun elementsByLocalName(el: Element, name: String): List { + val out = mutableListOf() + if (localName(el.tagName) == name) out.add(el) + collectByLocalName(el, name, out) + return out +} + +/** Detects whether an EXISTING `ThreadedComments`/`personList` document uses + * the default-namespace (Excel) convention or a prefixed (`x18tc:`, Google + * Sheets) one, by reading the root element's own tag name — so a NEW + * element appended into an existing part matches whatever convention that + * part already uses. `""` (no prefix) for a brand-new part this app mints + * itself. */ +private fun detectPrefix(doc: Document): String { + val rootTag = doc.documentElement.tagName + val i = rootTag.indexOf(':') + return if (i == -1) "" else rootTag.substring(0, i) +} + +private fun tcTag(prefix: String, local: String): String = if (prefix.isNotEmpty()) "$prefix:$local" else local + +private fun serializeXmlDocument(doc: Document): String { + val transformer = TransformerFactory.newInstance().newTransformer() + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes") + transformer.setOutputProperty(OutputKeys.METHOD, "xml") + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8") + val writer = java.io.StringWriter() + transformer.transform(DOMSource(doc), StreamResult(writer)) + return writer.toString() +} + +private val XML_DECL_RE = Regex("^<\\?xml[^>]*\\?>") + +/** Mirrors desktop's `serializeXlsxPart` (F2's own discipline): a CHANGED + * part is re-serialized fresh, with its XML declaration forced back to the + * ORIGINAL part's own declaration when one exists, or a sensible default for + * a brand-new part. An UNCHANGED part is never routed through this at + * all — see `serializeArchiveToFile` below. */ +private fun serializeXlsxPart(doc: Document, originalXml: String?): String { + val body = serializeXmlDocument(doc) + val decl = originalXml?.let { XML_DECL_RE.find(it)?.value } + ?: "" + return decl + body +} + +private fun ZipOutputStream.writeXlsxEntry(name: String, bytes: ByteArray) { + putNextEntry(ZipEntry(name)) + write(bytes) + closeEntry() +} + +// ----------------------------------------------------------------------- +// Exact OOXML shapes for brand-new parts — §4.2. +// ----------------------------------------------------------------------- + +/** Builds one `` element for a brand-new threaded-comment + * placeholder at 1-based `(col, row)`. §4.2's own confirmed difference from + * a genuine legacy Note's VML: every real threaded-placeholder sample + * (Mac Excel, Google Sheets, and a cross-checked-but-not-redistributed + * Windows Excel sample) OMITS ``/`` — present on a + * genuine Note's own VML, never on a threaded placeholder's. Anchor math is + * identical to the (retired) legacy-Notes VML builder's own formula. */ +private fun buildThreadedVmlShape(doc: Document, idNumber: Int, col: Int, row: Int): Element { + val shape = doc.createElement("v:shape") + shape.setAttribute("id", "_x0000_s$idNumber") + shape.setAttribute("type", "#_x0000_t202") + shape.setAttribute( + "style", + "position:absolute; margin-left:105.3pt;margin-top:10.5pt;width:97.8pt;height:59.1pt;z-index:1;visibility:hidden", + ) + shape.setAttribute("fillcolor", "infoBackground [80]") + shape.setAttribute("strokecolor", "none [81]") + shape.setAttribute("o:insetmode", "auto") + + val fill = doc.createElement("v:fill") + fill.setAttribute("color2", "infoBackground [80]") + shape.appendChild(fill) + val shadow = doc.createElement("v:shadow") + shadow.setAttribute("color", "none [81]") + shadow.setAttribute("obscured", "t") + shape.appendChild(shadow) + val vpath = doc.createElement("v:path") + vpath.setAttribute("o:connecttype", "none") + shape.appendChild(vpath) + + val textbox = doc.createElement("v:textbox") + textbox.setAttribute("style", "mso-direction-alt:auto") + textbox.setAttribute("inset", "1.3mm,1.3mm,2.5mm,2.5mm") + val div = doc.createElement("div") + div.setAttribute("style", "text-align:left") + textbox.appendChild(div) + shape.appendChild(textbox) + + val clientData = doc.createElement("x:ClientData") + clientData.setAttribute("ObjectType", "Note") + clientData.appendChild(doc.createElement("x:MoveWithCells")) + clientData.appendChild(doc.createElement("x:SizeWithCells")) + // Default anchor rect (exceljs's own `getDefaultRect`, 1-based col/row): + // l=col, t=max(row-2,0), r=col+2, b=t+4, fixed sub-cell fractions + // 6/14/2/16 — carried forward unchanged from the retired legacy-Notes + // builder (a positioning formula unrelated to note-vs-threaded). + val l = col + val t = maxOf(row - 2, 0) + val r = col + 2 + val b = t + 4 + val anchor = doc.createElement("x:Anchor") + anchor.textContent = listOf(l, 6, t, 14, r, 2, b, 16).joinToString(", ") + clientData.appendChild(anchor) + val autoFill = doc.createElement("x:AutoFill") + autoFill.textContent = "False" + clientData.appendChild(autoFill) + val rowEl = doc.createElement("x:Row") + rowEl.textContent = (row - 1).toString() + clientData.appendChild(rowEl) + val colEl = doc.createElement("x:Column") + colEl.textContent = (col - 1).toString() + clientData.appendChild(colEl) + shape.appendChild(clientData) + + return shape +} + +private fun nextVmlShapeId(vmlDoc: Document): Int { + var max = 1024 + for (el in elementsByTag(vmlDoc, "v:shape")) { + Regex("^_x0000_s(\\d+)$").find(el.getAttribute("id"))?.let { max = maxOf(max, it.groupValues[1].toInt()) } + } + return max + 1 +} + +private fun ensureXrNamespaceDeclared(doc: Document) { + if (doc.documentElement.getAttribute("xmlns:xr").isEmpty()) { + doc.documentElement.setAttribute("xmlns:xr", XR_NS) + } +} + +/** Review F2 (Medium-High), ported: reuse an EXISTING `tc={GUID}` author + * entry before minting a new one — the same "reused, never duplicated" rule + * `resolveOrCreatePerson` already applies to `persons.xml`. Closes the + * "grows by one leftover string per move, forever" defect the review found + * against the desktop reader. */ +private fun resolveOrAppendTcAuthor(commentsDoc: Document, guidBraced: String): Int { + var authorsEl = elementsByTag(commentsDoc, "authors").firstOrNull() + if (authorsEl == null) { + authorsEl = commentsDoc.createElement("authors") + commentsDoc.documentElement.insertBefore(authorsEl, commentsDoc.documentElement.firstChild) + } + val authorEls = elementsByTag(authorsEl, "author") + val target = normalizeGuid(guidBraced) + val existingIdx = authorEls.indexOfFirst { + val text = it.textContent ?: "" + isTcAuthorText(text) && normalizeGuid(text.substring(3)) == target + } + if (existingIdx != -1) return existingIdx + val newAuthor = commentsDoc.createElement("author") + newAuthor.textContent = "tc=$guidBraced" + authorsEl.appendChild(newAuthor) + return authorEls.size +} + +/** §4.1's rule: does the target cell already carry a GENUINE (non-`tc=`) + * Note in `commentsDoc`? Used to refuse `CELL_HAS_NOTE`. */ +private fun findGenuineNoteAtCell(commentsDoc: Document, cell: String): Boolean { + val authorsEl = elementsByTag(commentsDoc, "authors").firstOrNull() + val authorEls = authorsEl?.let { elementsByTag(it, "author") } ?: emptyList() + for (commentEl in elementsByTag(commentsDoc, "comment")) { + if (commentEl.getAttribute("ref") != cell) continue + val idx = commentEl.getAttribute("authorId").toIntOrNull() ?: continue + val authorText = authorEls.getOrNull(idx)?.textContent ?: "" + if (!isTcAuthorText(authorText)) return true + } + return false +} + +/** Finds the ONE legacy `` fronting for the thread whose root GUID + * is `guidBraced`, by resolving each ``'s own `authorId` through + * `` and matching the `tc={GUID}` string case-insensitively. */ +private fun findTcComment(commentsDoc: Document, guidBraced: String): Element? { + val authorsEl = elementsByTag(commentsDoc, "authors").firstOrNull() + val authorEls = authorsEl?.let { elementsByTag(it, "author") } ?: emptyList() + val target = "tc=${normalizeGuid(guidBraced)}" + for (commentEl in elementsByTag(commentsDoc, "comment")) { + val idx = commentEl.getAttribute("authorId").toIntOrNull() ?: continue + val authorText = authorEls.getOrNull(idx)?.textContent ?: "" + if (isTcAuthorText(authorText) && "tc=${normalizeGuid(authorText.substring(3))}" == target) return commentEl + } + return null +} + +private fun setXlsxCommentBody(doc: Document, commentEl: Element, bodyText: String) { + for (existing in elementsByTag(commentEl, "text")) { + existing.parentNode?.removeChild(existing) + } + val textEl = doc.createElement("text") + val rEl = doc.createElement("r") + val tEl = doc.createElement("t") + if (Regex("^\\s|\\n|\\s$").containsMatchIn(bodyText)) tEl.setAttribute("xml:space", "preserve") + tEl.textContent = bodyText + rEl.appendChild(tEl) + textEl.appendChild(rEl) + commentEl.appendChild(textEl) +} + +private fun appendPlaceholderComment(commentsDoc: Document, cellAddr: String, authorIdx: Int, guidBraced: String, bodyText: String) { + ensureXrNamespaceDeclared(commentsDoc) + val commentList = elementsByTag(commentsDoc, "commentList").first() + val commentEl = commentsDoc.createElement("comment") + commentEl.setAttribute("ref", cellAddr) + commentEl.setAttribute("authorId", authorIdx.toString()) + commentEl.setAttribute("xr:uid", guidBraced) + setXlsxCommentBody(commentsDoc, commentEl, bodyText) + commentList.appendChild(commentEl) +} + +// ----------------------------------------------------------------------- +// Per-worksheet / per-workbook wiring. +// ----------------------------------------------------------------------- + +private class SheetMeta(val name: String, val sheetId: Int, val rId: String, val partPath: String, val isChartsheet: Boolean) + +/** Parses `xl/workbook.xml`'s `` against `xl/_rels/workbook.xml.rels` + * to resolve each ``'s real worksheet PART path and whether it's a + * chartsheet — chartsheets are excluded from every cell-comment operation. */ +private fun parseSheetsFromWorkbook(workbookXml: String, workbookRelsXml: String): List { + val workbookDoc = parseXml(workbookXml) + val relsDoc = parseXml(workbookRelsXml) + val relMap = HashMap>() // id -> (target, type) + for (el in elementsByTag(relsDoc, "Relationship")) { + val id = el.getAttribute("Id") + if (id.isNotEmpty()) relMap[id] = el.getAttribute("Target") to el.getAttribute("Type") + } + val sheets = mutableListOf() + for (el in elementsByTag(workbookDoc, "sheet")) { + val name = el.getAttribute("name") + val sheetId = el.getAttribute("sheetId").toIntOrNull() + val rId = el.getAttribute("r:id") + val rel = relMap[rId] + if (rel == null || sheetId == null) continue // malformed wiring — not addressable + sheets.add(SheetMeta(name, sheetId, rId, resolveRelTarget("xl", rel.first), rel.second.endsWith("/chartsheet"))) + } + return sheets +} + +private class WorksheetCtx( + val partPath: String, + val sheetId: Int, + val sheetName: String, + val worksheetDoc: Document, + val worksheetXmlOriginal: String, + var worksheetChanged: Boolean = false, + val relsPartPath: String, + val relsDoc: Document, + val relsXmlOriginal: String?, + var relsChanged: Boolean = false, + var ambiguous: Boolean = false, + var commentsPartPath: String? = null, + var commentsDoc: Document? = null, + var commentsXmlOriginal: String? = null, + var commentsChanged: Boolean = false, + var vmlPartPath: String? = null, + var vmlDoc: Document? = null, + var vmlXmlOriginal: String? = null, + var vmlChanged: Boolean = false, + var threadedPartPath: String? = null, + var threadedDoc: Document? = null, + var threadedXmlOriginal: String? = null, + var threadedChanged: Boolean = false, +) + +/** Review F1 (High, android-xlsx-review): every part this write TOUCHES — + * the fixed top-level trio (`[Content_Types].xml`, `xl/workbook.xml`, + * `xl/_rels/workbook.xml.rels`), `persons.xml` if present, and whatever + * `getWorksheetContext` lazily resolves for a specific worksheet — is + * decompressed ONCE and its original bytes kept in `originalBytesByName`, + * keyed by zip entry name. `zip` (the source archive, kept OPEN by the + * caller for this whole load-mutate-serialize call — see + * `loadMutateSerializeXlsx`) is consulted directly at serialize time for + * every OTHER entry, which is streamed straight through + * (`streamCopyUntouchedEntry`) rather than ever being fully decompressed + * into memory here. This REPLACES the prior design (`entries: LinkedHashMap + * `, eagerly populated for the WHOLE archive regardless + * of what the mutation touched) the review's own F1 finding confirmed + * decompressed AND re-compressed every single part on every write, + * contradicting this module's own "never re-serialize an untouched part" + * claim and multiplying peak memory on a phone for any workbook carrying + * large unrelated parts (embedded images, many untouched worksheets). */ +private class WriteArchive( + val zip: ZipFile, + val originalBytesByName: MutableMap = mutableMapOf(), + val sheets: List, + val contentTypesDoc: Document, + val contentTypesOriginal: String, + var contentTypesChanged: Boolean = false, + val workbookRelsDoc: Document, + val workbookRelsOriginal: String, + var workbookRelsChanged: Boolean = false, + var personsPartPath: String, + var personsDoc: Document?, + var personsOriginal: String?, + var personsChanged: Boolean = false, + val worksheetContexts: MutableMap = mutableMapOf(), + var parsedBytesTotal: Long = 0L, + // Edit/delete build (2026-09-28): part names `cleanupEmptyCommentPartsIfNeeded` + // has removed outright (`commentsN.xml`/`vmlDrawingN.vml`/`threadedCommentN.xml`, + // once a worksheet's last comment of any kind is deleted). Desktop's JSZip + // has a real `zip.remove(path)` that makes a part vanish from the archive + // outright (xlsx-comments.ts's own `cleanupEmptyCommentPartsIfNeeded`); + // Kotlin's `ZipFile` is read-only and `serializeArchiveToFile` streams + // untouched entries straight from it, so this set is what tells that + // function "never write this entry back, from either `overrides` or the + // original archive" — the Kotlin-side equivalent of a genuine removal. + val removedPartNames: MutableSet = mutableSetOf(), +) + +/** Reads (and, in `tracker`, REMEMBERS) one named part's raw bytes — the lazy + * replacement for the old eager `entries` map. Returns `null` (and tracks + * nothing) if the part doesn't exist. Every caller that wants a part's bytes + * available for a byte-identical write-back (never re-serialized unless + * actually changed) goes through this, never a direct `zip.getInputStream` + * call — that discipline is what lets `serializeArchiveToFile` tell "this + * operation touched it" (present in the map, even if unchanged) apart from + * "genuinely untouched" (stream-copy) by a single map lookup. Takes the + * tracking map directly, not a whole `WriteArchive`, so `loadArchiveForWrite` + * can populate it BEFORE the (immutable-by-`val`) `WriteArchive` itself + * exists. */ +private fun readAndTrackPart(zip: ZipFile, name: String, tracker: MutableMap): String? { + val entry = zip.getEntry(name) ?: return null + val bytes = readEntryBounded(zip, entry) + tracker[name] = bytes + return bytes.toString(Charsets.UTF_8) +} + +/** `WriteArchive`-taking overload for every call site AFTER the archive + * itself exists (`getWorksheetContext` and friends) — just forwards to the + * map-taking version above using the archive's own tracker. */ +private fun readAndTrackPart(archive: WriteArchive, name: String): String? = + readAndTrackPart(archive.zip, name, archive.originalBytesByName) + +/** Picks the smallest positive integer not already used by an + * `xl/comments.xml`, `xl/drawings/vmlDrawing.vml`, OR + * `xl/threadedComments/threadedComment.xml` part ANYWHERE in the + * archive — "skip the gap, don't reserve it" (§4.2). Scans `archive.zip`'s + * own entry NAMES directly (central-directory metadata only, exactly like + * the read path's own size guard — no decompression), rather than the old + * `entries.keys` map, since that map no longer holds every part's name + * after the F1 fix (only the ones actually touched). */ +private fun mintPartNumber(archive: WriteArchive): Int { + var max = 0 + for (entry in Collections.list(archive.zip.entries())) { + val p = entry.name + Regex("^xl/comments(\\d+)\\.xml$").find(p)?.let { max = maxOf(max, it.groupValues[1].toInt()) } + Regex("^xl/drawings/vmlDrawing(\\d+)\\.vml$").find(p)?.let { max = maxOf(max, it.groupValues[1].toInt()) } + Regex("^xl/threadedComments/threadedComment(\\d+)\\.xml$").find(p)?.let { max = maxOf(max, it.groupValues[1].toInt()) } + } + return max + 1 +} + +private fun extractPartNumber(partPath: String): Int { + val m = Regex("(\\d+)\\.[a-zA-Z]+$").find(partPath) + return m?.groupValues?.get(1)?.toIntOrNull() ?: 1 +} + +private fun nextRelId(relsDoc: Document): String { + var max = 0 + for (el in elementsByTag(relsDoc, "Relationship")) { + Regex("^rId(\\d+)$").find(el.getAttribute("Id"))?.let { max = maxOf(max, it.groupValues[1].toInt()) } + } + return "rId${max + 1}" +} + +private fun addXlsxRelationship(relsDoc: Document, id: String, type: String, target: String) { + val el = relsDoc.createElement("Relationship") + el.setAttribute("Id", id) + el.setAttribute("Type", type) + el.setAttribute("Target", target) + relsDoc.documentElement.appendChild(el) +} + +private fun addContentTypeOverride(contentTypesDoc: Document, partName: String, contentType: String) { + val exists = elementsByTag(contentTypesDoc, "Override").any { it.getAttribute("PartName") == partName } + if (exists) return + val el = contentTypesDoc.createElement("Override") + el.setAttribute("PartName", partName) + el.setAttribute("ContentType", contentType) + contentTypesDoc.documentElement.appendChild(el) +} + +/** Creates a brand-new comments{N}.xml/vmlDrawing{N}.vml pair, wires the + * worksheet's own rels and `[Content_Types].xml`, and inserts + * `` at its schema position: before the first of + * `LEGACY_DRAWING_SUCCESSORS` the sheet already has, else last (see that + * constant for why the earlier "always last" rule was wrong). Also review + * F5 (Low): declares `xmlns:r` + * on the worksheet root defensively before ever setting `r:id`. */ +private fun createLegacyPair(archive: WriteArchive, ctx: WorksheetCtx, n: Int) { + ctx.commentsPartPath = "xl/comments$n.xml" + ctx.vmlPartPath = "xl/drawings/vmlDrawing$n.vml" + ctx.commentsXmlOriginal = null + ctx.vmlXmlOriginal = null + ctx.commentsDoc = parseXml(EMPTY_COMMENTS_XML) + ctx.vmlDoc = parseXml(EMPTY_VML_XML) + + var vmlDefaultEl = elementsByTag(archive.contentTypesDoc, "Default").find { it.getAttribute("Extension").lowercase() == "vml" } + if (vmlDefaultEl == null) { + vmlDefaultEl = archive.contentTypesDoc.createElement("Default") + vmlDefaultEl.setAttribute("Extension", "vml") + vmlDefaultEl.setAttribute("ContentType", VML_CONTENT_TYPE) + archive.contentTypesDoc.documentElement.appendChild(vmlDefaultEl) + } + val override = archive.contentTypesDoc.createElement("Override") + override.setAttribute("PartName", "/${ctx.commentsPartPath}") + override.setAttribute("ContentType", COMMENTS_CONTENT_TYPE) + vmlDefaultEl.parentNode?.insertBefore(override, vmlDefaultEl.nextSibling) + archive.contentTypesChanged = true + + val worksheetDir = dirnameOfPart(ctx.partPath) + val commentsRelId = nextRelId(ctx.relsDoc) + addXlsxRelationship(ctx.relsDoc, commentsRelId, COMMENTS_REL_TYPE, relativizeTarget(worksheetDir, ctx.commentsPartPath!!)) + val vmlRelId = nextRelId(ctx.relsDoc) + addXlsxRelationship(ctx.relsDoc, vmlRelId, VML_REL_TYPE, relativizeTarget(worksheetDir, ctx.vmlPartPath!!)) + ctx.relsChanged = true + + if (ctx.worksheetDoc.documentElement.getAttribute("xmlns:r").isEmpty()) { + ctx.worksheetDoc.documentElement.setAttribute("xmlns:r", OFFICE_DOCUMENT_R_NS) + } + val legacyDrawing = ctx.worksheetDoc.createElement("legacyDrawing") + legacyDrawing.setAttribute("r:id", vmlRelId) + val root = ctx.worksheetDoc.documentElement + val children = root.childNodes + var successor: org.w3c.dom.Node? = null + for (i in 0 until children.length) { + val node = children.item(i) + if (node.nodeType == org.w3c.dom.Node.ELEMENT_NODE && localName((node as Element).tagName) in LEGACY_DRAWING_SUCCESSORS) { + successor = node + break + } + } + root.insertBefore(legacyDrawing, successor) + ctx.worksheetChanged = true +} + +/** Worksheet children the OOXML schema (`CT_Worksheet`, a strict sequence) + * puts AFTER ``. WHY (2026-09-28 PR review, mirrors + * xlsx-comments.ts): appending it after a Table's `` or an + * `` made Excel report "We found a problem with some content" and + * drop the comment. */ +private val LEGACY_DRAWING_SUCCESSORS = setOf("legacyDrawingHF", "drawingHF", "picture", "oleObjects", "controls", "webPublishItems", "tableParts", "extLst") + +/** §4.2: both relationships are "implicit" — creating the threaded part needs + * only a rels entry + content-types Override, never any worksheet-content + * wiring beyond that. */ +private fun createThreadedPart(archive: WriteArchive, ctx: WorksheetCtx, n: Int) { + ctx.threadedPartPath = "xl/threadedComments/threadedComment$n.xml" + ctx.threadedXmlOriginal = null + ctx.threadedDoc = parseXml(EMPTY_THREADED_COMMENTS_XML) + + val worksheetDir = dirnameOfPart(ctx.partPath) + val relId = nextRelId(ctx.relsDoc) + addXlsxRelationship(ctx.relsDoc, relId, THREADED_COMMENT_REL_TYPE, relativizeTarget(worksheetDir, ctx.threadedPartPath!!)) + ctx.relsChanged = true + + addContentTypeOverride(archive.contentTypesDoc, "/${ctx.threadedPartPath}", THREADED_COMMENT_CONTENT_TYPE) + archive.contentTypesChanged = true +} + +private fun ensureThreadedWiring(archive: WriteArchive, ctx: WorksheetCtx) { + if (ctx.commentsDoc == null) { + val n = mintPartNumber(archive) + createLegacyPair(archive, ctx, n) + createThreadedPart(archive, ctx, n) + } else if (ctx.threadedDoc == null) { + val n = extractPartNumber(ctx.commentsPartPath!!) + createThreadedPart(archive, ctx, n) + } +} + +private fun ensurePersonsPart(archive: WriteArchive) { + if (archive.personsDoc != null) return + archive.personsDoc = parseXml(EMPTY_PERSON_XML) + archive.personsOriginal = null + archive.personsChanged = true + + val alreadyWired = elementsByTag(archive.workbookRelsDoc, "Relationship").any { it.getAttribute("Type") == PERSON_REL_TYPE } + if (!alreadyWired) { + val relId = nextRelId(archive.workbookRelsDoc) + addXlsxRelationship(archive.workbookRelsDoc, relId, PERSON_REL_TYPE, relativizeTarget("xl", archive.personsPartPath)) + archive.workbookRelsChanged = true + } + addContentTypeOverride(archive.contentTypesDoc, "/${archive.personsPartPath}", PERSON_CONTENT_TYPE) + archive.contentTypesChanged = true +} + +/** §4.2's reuse-not-duplicate rule: before minting a new ``, look for + * an existing entry with `providerId="YouCoded"` AND a matching + * `displayName`. `userId` is omitted (matching Google Sheets' own + * precedent). */ +private fun resolveOrCreatePerson(archive: WriteArchive, author: CommentAuthor): String { + ensurePersonsPart(archive) + val displayName = commentAuthorToDisplayName(author) + val existing = elementsByLocalName(archive.personsDoc!!, "person").find { + it.getAttribute("providerId") == "YouCoded" && it.getAttribute("displayName") == displayName + } + if (existing != null) { + val id = existing.getAttribute("id") + if (id.isNotEmpty()) return id + } + val id = mintGuid() + val personEl = archive.personsDoc!!.createElement("person") + personEl.setAttribute("displayName", displayName) + personEl.setAttribute("id", id) + personEl.setAttribute("providerId", "YouCoded") + archive.personsDoc!!.documentElement.appendChild(personEl) + archive.personsChanged = true + return id +} + +// ----------------------------------------------------------------------- +// Thread lookup — root/reply element helpers, shared by read and write. +// ----------------------------------------------------------------------- + +private fun isRootThreadedComment(el: Element): Boolean = el.getAttribute("parentId").isEmpty() + +private fun repliesOfRoot(threadedDoc: Document, rootIdBraced: String): List { + val target = normalizeGuid(rootIdBraced) + return elementsByLocalName(threadedDoc, "threadedComment").filter { normalizeGuid(it.getAttribute("parentId")) == target } +} + +private fun textOfThreadedComment(el: Element): String = elementsByLocalName(el, "text").firstOrNull()?.textContent ?: "" + +private fun findRootByRefAndGuid(threadedDoc: Document, ref: String, guidNorm: String): Element? = + elementsByLocalName(threadedDoc, "threadedComment").find { + isRootThreadedComment(it) && it.getAttribute("ref") == ref && normalizeGuid(it.getAttribute("id")) == guidNorm + } + +private fun findAnyRootAtRef(threadedDoc: Document, ref: String): Element? = + elementsByLocalName(threadedDoc, "threadedComment").find { isRootThreadedComment(it) && it.getAttribute("ref") == ref } + +private fun findRootsByGuid(threadedDoc: Document, guidNorm: String): List = + elementsByLocalName(threadedDoc, "threadedComment").filter { isRootThreadedComment(it) && normalizeGuid(it.getAttribute("id")) == guidNorm } + +private class ThreadTarget(val ctx: WorksheetCtx, val cell: String, val rootEl: Element) + +// ============================================================================= +// Write errors/result — mirrors desktop's `XlsxWriteError`/`XlsxWriteResult` +// (xlsx-comments.ts), used both for the internal archive-mutation helpers +// below AND the public API at the bottom of this file — same convention +// `DocxComments.kt`'s own `DocxWriteResult` already establishes. +// ----------------------------------------------------------------------- +// +// 'comment-not-found': a reply/resolve/reopen/move `id` that can't be +// resolved to a real thread root (hinted lookup AND full-workbook fallback +// both miss). +// 'ambiguous-comment-id' (design review round 2, F3): the fallback scan found +// MORE than one root sharing the embedded GUID. +// 'invalid-selector': add's `selector` (or move's `newSelector`) isn't a +// `Cell` selector, names a malformed cell reference, or omits `sheet` on a +// workbook with more than one tab. +// 'sheet-not-found': a named `sheet` doesn't exist (or names a chartsheet). +// 'cell-has-note' (§4.1): the target cell already carries a GENUINE Note. +// 'cell-already-has-comment' (§4.2's one-thread-per-cell WRITE policy). +// 'destination-cell-occupied': move's destination already carries its OWN +// thread. +// 'ambiguous-comment-wiring': the target worksheet's legacy/threaded wiring +// is a partial or inconsistent combination no real writer produces. +// 'comment-scan-too-large' (review F3): the fallback scan's own aggregate +// byte budget was exhausted before a match was found. +// ============================================================================= + +enum class XlsxWriteError(val wire: String) { + INVALID_XLSX("invalid-xlsx"), + ARCHIVE_TOO_LARGE("archive-too-large"), + TOO_MANY_COMMENTS("too-many-comments"), + UNSAFE_XML("unsafe-xml"), + COMMENT_NOT_FOUND("comment-not-found"), + AMBIGUOUS_COMMENT_ID("ambiguous-comment-id"), + INVALID_SELECTOR("invalid-selector"), + SHEET_NOT_FOUND("sheet-not-found"), + CELL_HAS_NOTE("cell-has-note"), + CELL_ALREADY_HAS_COMMENT("cell-already-has-comment"), + DESTINATION_CELL_OCCUPIED("destination-cell-occupied"), + AMBIGUOUS_COMMENT_WIRING("ambiguous-comment-wiring"), + COMMENT_SCAN_TOO_LARGE("comment-scan-too-large"), + READ_FAILED("read-failed"), + BACKUP_FAILED("backup-failed"), + WRITE_FAILED("write-failed"), + VERIFY_FAILED("verify-failed"), + FILE_OPEN_ELSEWHERE("file-open-elsewhere"), +} + +sealed class XlsxWriteResult { + data class Ok(val value: T) : XlsxWriteResult() + data class Err(val error: XlsxWriteError) : XlsxWriteResult() +} + +/** §4.2's corrected id-resolution algorithm (design review 1, F1; ambiguity + * refusal design review round 2, F3): (1) open the HINTED worksheet and + * match the embedded GUID against a root at the hinted `ref`; (2) on a miss, + * fall back to a full-workbook scan for a root whose `id` matches; (3) + * refuse `COMMENT_NOT_FOUND` only if NEITHER finds it, or + * `AMBIGUOUS_COMMENT_ID` if the fallback scan finds more than one. */ +private fun resolveXlsxThreadTarget(archive: WriteArchive, id: String): XlsxWriteResult { + val parsed = parseXlsxThreadId(id) ?: return XlsxWriteResult.Err(XlsxWriteError.COMMENT_NOT_FOUND) + val guidNorm = normalizeGuid(parsed.guid) + + val hintedSheet = archive.sheets.find { it.sheetId == parsed.sheetId && !it.isChartsheet } + if (hintedSheet != null) { + when (val r = getWorksheetContext(archive, hintedSheet)) { + is XlsxWriteResult.Err -> { + if (r.error == XlsxWriteError.ARCHIVE_TOO_LARGE) return r + // A missing worksheet part falls through to the fallback + // scan below — the hint is only ever a locate-first shortcut. + } + is XlsxWriteResult.Ok -> { + val ctx = r.value + if (ctx.ambiguous) return XlsxWriteResult.Err(XlsxWriteError.AMBIGUOUS_COMMENT_WIRING) + val td = ctx.threadedDoc + if (td != null) { + val hit = findRootByRefAndGuid(td, parsed.cell, guidNorm) + if (hit != null) return XlsxWriteResult.Ok(ThreadTarget(ctx, parsed.cell, hit)) + } + } + } + } + + val matches = mutableListOf() + var sawAmbiguousSheet = false + var scanBudgetExceeded = false + for (sheetMeta in archive.sheets) { + if (sheetMeta.isChartsheet) continue + if (!archive.worksheetContexts.containsKey(sheetMeta.partPath) && archive.parsedBytesTotal > MAX_FALLBACK_SCAN_BYTES) { + scanBudgetExceeded = true + continue + } + val r = getWorksheetContext(archive, sheetMeta) + if (r is XlsxWriteResult.Err) { + if (r.error == XlsxWriteError.ARCHIVE_TOO_LARGE) return r + continue + } + val ctx = (r as XlsxWriteResult.Ok).value + if (ctx.ambiguous) { + sawAmbiguousSheet = true + continue + } + val td = ctx.threadedDoc ?: continue + for (rootEl in findRootsByGuid(td, guidNorm)) { + matches.add(ThreadTarget(ctx, rootEl.getAttribute("ref"), rootEl)) + } + } + if (matches.isEmpty()) { + if (sawAmbiguousSheet) return XlsxWriteResult.Err(XlsxWriteError.AMBIGUOUS_COMMENT_WIRING) + if (scanBudgetExceeded) return XlsxWriteResult.Err(XlsxWriteError.COMMENT_SCAN_TOO_LARGE) + return XlsxWriteResult.Err(XlsxWriteError.COMMENT_NOT_FOUND) + } + if (matches.size > 1) return XlsxWriteResult.Err(XlsxWriteError.AMBIGUOUS_COMMENT_ID) + return XlsxWriteResult.Ok(matches[0]) +} + +/** §4.2's rule: `sheet` is required only when the workbook has more than one + * (non-chartsheet) tab. Reused by both add's `selector` and move's + * `newSelector`. */ +private fun resolveWorksheetForSelector(archive: WriteArchive, sel: CellSelector): XlsxWriteResult { + if (!isValidCellAddress(sel.cell)) return XlsxWriteResult.Err(XlsxWriteError.INVALID_SELECTOR) + val realSheets = archive.sheets.filter { !it.isChartsheet } + val sheetMeta = if (sel.sheet != null) { + realSheets.find { it.name == sel.sheet } ?: return XlsxWriteResult.Err(XlsxWriteError.SHEET_NOT_FOUND) + } else { + if (realSheets.size != 1) return XlsxWriteResult.Err(XlsxWriteError.INVALID_SELECTOR) + realSheets[0] + } + return when (val r = getWorksheetContext(archive, sheetMeta)) { + is XlsxWriteResult.Err -> r + is XlsxWriteResult.Ok -> if (r.value.ambiguous) XlsxWriteResult.Err(XlsxWriteError.AMBIGUOUS_COMMENT_WIRING) else r + } +} + +/** Loads (or returns the already-loaded, cached-by-partPath) context for one + * worksheet: its own XML, its own rels, and — if wiring is unambiguous — + * the comments/VML/threadedComment parts it already points at. `ambiguous` + * is set when the legacy trio is a partial/inconsistent combination, OR a + * threadedComment relationship exists with no matching part, OR a threaded + * part exists without its accompanying legacy pair (a shape no real + * Excel/Google-Sheets writer produces) — refused rather than guessed at. */ +private fun getWorksheetContext(archive: WriteArchive, sheetMeta: SheetMeta): XlsxWriteResult { + archive.worksheetContexts[sheetMeta.partPath]?.let { return XlsxWriteResult.Ok(it) } + + // Review F1: lazy, per-part reads via `readAndTrackPart` — the ONLY + // parts this call decompresses are the ones a mutation actually needs to + // resolve THIS worksheet's own wiring, never the rest of the archive. + val worksheetXmlOriginal = readAndTrackPart(archive, sheetMeta.partPath) ?: return XlsxWriteResult.Err(XlsxWriteError.INVALID_SELECTOR) + val worksheetDoc = parseXml(worksheetXmlOriginal) + + val relsPartPath = worksheetRelsPathFor(sheetMeta.partPath) + val relsXmlOriginal = readAndTrackPart(archive, relsPartPath) + val relsDoc = parseXml(relsXmlOriginal ?: EMPTY_RELS_XML) + + val legacyDrawingEl = elementsByTag(worksheetDoc, "legacyDrawing").firstOrNull() + val relationshipEls = elementsByTag(relsDoc, "Relationship") + val commentsRel = relationshipEls.find { it.getAttribute("Type") == COMMENTS_REL_TYPE } + val vmlRel = relationshipEls.find { it.getAttribute("Type") == VML_REL_TYPE } + val threadedRel = relationshipEls.find { it.getAttribute("Type") == THREADED_COMMENT_REL_TYPE } + + var ambiguous = false + var commentsPartPath: String? = null + var vmlPartPath: String? = null + var commentsDoc: Document? = null + var vmlDoc: Document? = null + var commentsXmlOriginal: String? = null + var vmlXmlOriginal: String? = null + var legacyExisting = false + + if (legacyDrawingEl != null || commentsRel != null || vmlRel != null) { + if (legacyDrawingEl != null && commentsRel != null && vmlRel != null && + legacyDrawingEl.getAttribute("r:id") == vmlRel.getAttribute("Id") + ) { + val worksheetDir = dirnameOfPart(sheetMeta.partPath) + val cPath = resolveRelTarget(worksheetDir, commentsRel.getAttribute("Target")) + val vPath = resolveRelTarget(worksheetDir, vmlRel.getAttribute("Target")) + val cXml = readAndTrackPart(archive, cPath) + val vXml = readAndTrackPart(archive, vPath) + if (cXml == null || vXml == null) { + ambiguous = true + } else { + commentsPartPath = cPath + vmlPartPath = vPath + commentsXmlOriginal = cXml + vmlXmlOriginal = vXml + commentsDoc = parseXml(commentsXmlOriginal) + vmlDoc = parseXml(vmlXmlOriginal) + legacyExisting = true + } + } else { + ambiguous = true + } + } + + var threadedPartPath: String? = null + var threadedDoc: Document? = null + var threadedXmlOriginal: String? = null + var threadedExisting = false + if (threadedRel != null) { + val tPath = resolveRelTarget(dirnameOfPart(sheetMeta.partPath), threadedRel.getAttribute("Target")) + val tXml = readAndTrackPart(archive, tPath) + if (tXml == null) { + ambiguous = true + } else { + threadedPartPath = tPath + threadedXmlOriginal = tXml + threadedDoc = parseXml(threadedXmlOriginal) + threadedExisting = true + } + } + // §4.2: a threaded part is never observed without its accompanying legacy + // pair in any real sample — treat that as ambiguous rather than silently + // reconstructing a legacy pair that never existed. + if (threadedExisting && !legacyExisting) ambiguous = true + + val ctx = WorksheetCtx( + partPath = sheetMeta.partPath, + sheetId = sheetMeta.sheetId, + sheetName = sheetMeta.name, + worksheetDoc = worksheetDoc, + worksheetXmlOriginal = worksheetXmlOriginal, + relsPartPath = relsPartPath, + relsDoc = relsDoc, + relsXmlOriginal = relsXmlOriginal, + ambiguous = ambiguous, + commentsPartPath = commentsPartPath, + commentsDoc = commentsDoc, + commentsXmlOriginal = commentsXmlOriginal, + vmlPartPath = vmlPartPath, + vmlDoc = vmlDoc, + vmlXmlOriginal = vmlXmlOriginal, + threadedPartPath = threadedPartPath, + threadedDoc = threadedDoc, + threadedXmlOriginal = threadedXmlOriginal, + ) + // Review F3: charge this FRESH parse's own byte cost against the + // archive's running total, bounding the fallback scan's aggregate parse + // cost (a cache hit above never reaches here). + archive.parsedBytesTotal += worksheetXmlOriginal.length.toLong() + (relsXmlOriginal?.length?.toLong() ?: 0L) + + (commentsXmlOriginal?.length?.toLong() ?: 0L) + (vmlXmlOriginal?.length?.toLong() ?: 0L) + + (threadedXmlOriginal?.length?.toLong() ?: 0L) + archive.worksheetContexts[sheetMeta.partPath] = ctx + return XlsxWriteResult.Ok(ctx) +} + +/** Removes a thread's root+replies from `ctx.threadedDoc`, its ONE legacy + * `` from `ctx.commentsDoc`, and its `` from `ctx.vmlDoc` + * (matched by ``/``) — used only by Move, to vacate the OLD + * `[sheet, cell]` pair. */ +private fun removeThreadFromWorksheet(ctx: WorksheetCtx, cell: String, rootEl: Element) { + val rootId = rootEl.getAttribute("id") + for (reply in repliesOfRoot(ctx.threadedDoc!!, rootId)) { + reply.parentNode?.removeChild(reply) + } + rootEl.parentNode?.removeChild(rootEl) + ctx.threadedChanged = true + + val commentEl = ctx.commentsDoc?.let { findTcComment(it, rootId) } + if (commentEl != null) { + commentEl.parentNode?.removeChild(commentEl) + ctx.commentsChanged = true + } + val (col, row) = parseCellRef(cell) ?: return + val zeroRow = (row - 1).toString() + val zeroCol = (col - 1).toString() + ctx.vmlDoc?.let { vmlDoc -> + for (shape in elementsByTag(vmlDoc, "v:shape")) { + val clientData = elementsByTag(shape, "x:ClientData").firstOrNull() ?: continue + val rowEl = elementsByTag(clientData, "x:Row").firstOrNull() + val colEl = elementsByTag(clientData, "x:Column").firstOrNull() + if (rowEl?.textContent == zeroRow && colEl?.textContent == zeroCol) { + shape.parentNode?.removeChild(shape) + ctx.vmlChanged = true + break + } + } + } +} + +// ----------------------------------------------------------------------- +// Edit/delete build (2026-09-28, design doc §"Edit and delete"): anyone's +// comment/reply can be edited or deleted, no "edited" marker is ever stored +// or shown, and deleting a THREAD's first comment deletes the whole thread +// — decisions.json (doc-comments.edit-delete.questions.answers.json). +// Mirrors xlsx-comments.ts's own "Edit/delete build" section field-for-field. +// ----------------------------------------------------------------------- + +/** Shared across every reply-id convention this feature mints — see + * `DocxComments.kt`'s own copy of this helper for the full reasoning (kept + * as a small per-module, file-private duplicate rather than shared: neither + * module otherwise depends on the other, and the shape is one line). + * Mirrors `replyOrdinalFromId` (xlsx-comments.ts). */ +private fun replyOrdinalFromId(replyId: String): Int? = Regex("-r(\\d+)$").find(replyId)?.groupValues?.get(1)?.toIntOrNull() + +/** Overwrites a `` element's OWN `` child in place — + * `ref`/`dT`/`personId`/`id`/`parentId`/`done` all stay untouched, so "keep + * author/date" (equally true here: `personId`+`dT` are this format's + * author/date) holds simply by never touching them. Mirrors + * `setThreadedCommentText` (xlsx-comments.ts). */ +private fun setThreadedCommentText(doc: Document, el: Element, text: String) { + val existing = elementsByLocalName(el, "text").firstOrNull() + if (existing != null) { + existing.textContent = text + return + } + val prefix = detectPrefix(doc) + val textEl = doc.createElement(tcTag(prefix, "text")) + textEl.textContent = text + el.appendChild(textEl) +} + +/** The legacy placeholder is REBUILT WHOLE from the thread's current full + * transcript after ANY edit or delete, never patched — the same rule + * `mutateReplyToXlsxComment` already follows for a new reply, reused here + * so edit/delete can never leave root/reply text out of sync with the + * placeholder a legacy Excel reader still shows. Mirrors + * `rebuildPlaceholderForRoot` (xlsx-comments.ts). */ +private fun rebuildPlaceholderForRoot(ctx: WorksheetCtx, rootEl: Element) { + val commentsDoc = ctx.commentsDoc ?: return + val threadedDoc = ctx.threadedDoc ?: return + val rootId = rootEl.getAttribute("id") + val commentEl = findTcComment(commentsDoc, rootId) ?: return + val repliesSorted = repliesOfRoot(threadedDoc, rootId).sortedBy { parseThreadedDate(it.getAttribute("dT")) } + val body = buildPlaceholderBody(textOfThreadedComment(rootEl), repliesSorted.map { textOfThreadedComment(it) }) + setXlsxCommentBody(commentsDoc, commentEl, body) + ctx.commentsChanged = true +} + +/** §4.2's own `providerId="YouCoded"`/`displayName`-reuse person entries, + * looked up the same way `readXlsxCommentsFromZip` builds its own person + * map — duplicated narrowly here since edit-reply is the only write path + * that needs a reply's AUTHOR back out (every other write already knows the + * author it's writing; this one only ever changes text). Mirrors + * `personDisplayName` (xlsx-comments.ts). */ +private fun personDisplayName(archive: WriteArchive, personIdBraced: String): String { + val doc = archive.personsDoc ?: return "Unknown" + val target = normalizeGuid(personIdBraced) + for (el in elementsByLocalName(doc, "person")) { + if (normalizeGuid(el.getAttribute("id")) == target) { + return el.getAttribute("displayName").ifEmpty { "Unknown" } + } + } + return "Unknown" +} + +/** Task brief: "if that was the sheet's last comment, remove the now-empty + * parts, their rels, content-type overrides and the ``, + * leaving the workbook exactly as if it never had comments on that sheet." + * Never built for Move (which always re-inserts the thread elsewhere, so a + * worksheet it vacates is never checked for this) — genuinely new for + * Delete. "No comments left" means neither a threaded comment NOR a legacy + * `` of ANY kind remains — a genuine Note on this same worksheet + * (§4.1, never touched by this module) keeps the parts alive. Mirrors + * `cleanupEmptyCommentPartsIfNeeded` (xlsx-comments.ts); see + * `WriteArchive.removedPartNames`'s own doc comment for how a genuine + * removal is represented on this read-only-`ZipFile` platform. */ +private fun cleanupEmptyCommentPartsIfNeeded(archive: WriteArchive, ctx: WorksheetCtx) { + val threadedDoc = ctx.threadedDoc ?: return + val commentsDoc = ctx.commentsDoc ?: return + if (ctx.vmlDoc == null) return + val anyThreaded = elementsByLocalName(threadedDoc, "threadedComment").isNotEmpty() + val anyLegacyComment = elementsByTag(commentsDoc, "comment").isNotEmpty() + if (anyThreaded || anyLegacyComment) return + + ctx.commentsPartPath?.let { archive.removedPartNames.add(it) } + ctx.vmlPartPath?.let { archive.removedPartNames.add(it) } + ctx.threadedPartPath?.let { archive.removedPartNames.add(it) } + + for (el in elementsByTag(ctx.relsDoc, "Relationship")) { + val type = el.getAttribute("Type") + if (type == COMMENTS_REL_TYPE || type == VML_REL_TYPE || type == THREADED_COMMENT_REL_TYPE) { + el.parentNode?.removeChild(el) + } + } + ctx.relsChanged = true + + // The shared `vml` Default extension entry in [Content_Types].xml is left + // alone — it may still be needed by another worksheet's own vmlDrawing + // part; only the two per-worksheet Overrides this sheet minted are removed. + for (el in elementsByTag(archive.contentTypesDoc, "Override")) { + val name = el.getAttribute("PartName") + if (name == "/${ctx.commentsPartPath}" || name == "/${ctx.threadedPartPath}") { + el.parentNode?.removeChild(el) + } + } + archive.contentTypesChanged = true + + val legacyDrawingEl = elementsByTag(ctx.worksheetDoc, "legacyDrawing").firstOrNull() + legacyDrawingEl?.parentNode?.removeChild(legacyDrawingEl) + ctx.worksheetChanged = true + + // Nulled so `serializeArchiveToFile`'s own `ctx.commentsDoc != null && + // commentsPath != null`-shaped gates never try to write a part this + // function just removed outright. + ctx.commentsPartPath = null + ctx.vmlPartPath = null + ctx.threadedPartPath = null + ctx.commentsDoc = null + ctx.vmlDoc = null + ctx.threadedDoc = null +} + +private class ThreadSnapshotReply(val id: String, val personId: String, val dT: String, val text: String) +private class ThreadSnapshot(val id: String, val personId: String, val dT: String, val done: Boolean, val text: String, val replies: List) + +private fun snapshotThread(ctx: WorksheetCtx, rootEl: Element): ThreadSnapshot { + val rootId = rootEl.getAttribute("id") + val replies = repliesOfRoot(ctx.threadedDoc!!, rootId) + .sortedBy { parseThreadedDate(it.getAttribute("dT")) } + .map { ThreadSnapshotReply(it.getAttribute("id"), it.getAttribute("personId"), it.getAttribute("dT"), textOfThreadedComment(it)) } + return ThreadSnapshot( + id = rootId, + personId = rootEl.getAttribute("personId"), + dT = rootEl.getAttribute("dT"), + done = rootEl.getAttribute("done") == "1", + text = textOfThreadedComment(rootEl), + replies = replies, + ) +} + +/** Appends a `` element (root or reply) matching the target + * document's own namespace-prefix convention (§4.2's load-bearing finding — + * a brand-new part always uses the default-namespace convention). */ +private fun appendThreadedElement( + threadedDoc: Document, + ref: String, + dT: String, + personId: String, + id: String, + text: String, + parentId: String? = null, + done: Boolean = false, +) { + val prefix = detectPrefix(threadedDoc) + val el = threadedDoc.createElement(tcTag(prefix, "threadedComment")) + el.setAttribute("ref", ref) + el.setAttribute("dT", dT) + el.setAttribute("personId", personId) + el.setAttribute("id", id) + if (parentId != null) el.setAttribute("parentId", parentId) + if (done) el.setAttribute("done", "1") + val textEl = threadedDoc.createElement(tcTag(prefix, "text")) + textEl.textContent = text + el.appendChild(textEl) + threadedDoc.documentElement.appendChild(el) +} + +/** Re-runs `ensureThreadedWiring`, appends `snapshot`'s root+replies verbatim + * (SAME ids/personIds/timestamps/text/done state — "never minting fresh + * GUIDs on a move"), the matching legacy placeholder, and a fresh + * ``. Used by both Add (a snapshot with no replies) and Move. */ +private fun insertThreadIntoWorksheet(archive: WriteArchive, ctx: WorksheetCtx, cell: String, snapshot: ThreadSnapshot) { + ensureThreadedWiring(archive, ctx) + + appendThreadedElement(ctx.threadedDoc!!, cell, snapshot.dT, snapshot.personId, snapshot.id, snapshot.text, done = snapshot.done) + ctx.threadedChanged = true + for (reply in snapshot.replies) { + appendThreadedElement(ctx.threadedDoc!!, cell, reply.dT, reply.personId, reply.id, reply.text, parentId = snapshot.id) + } + + val authorIdx = resolveOrAppendTcAuthor(ctx.commentsDoc!!, snapshot.id) + val body = buildPlaceholderBody(snapshot.text, snapshot.replies.map { it.text }) + appendPlaceholderComment(ctx.commentsDoc!!, cell, authorIdx, snapshot.id, body) + ctx.commentsChanged = true + + val (col, row) = parseCellRef(cell)!! + val shape = buildThreadedVmlShape(ctx.vmlDoc!!, nextVmlShapeId(ctx.vmlDoc!!), col, row) + ctx.vmlDoc!!.documentElement.appendChild(shape) + ctx.vmlChanged = true +} + +// ----------------------------------------------------------------------- +// Archive load/serialize for a WRITE. +// ----------------------------------------------------------------------- + +/** Loads the FIXED top-level trio plus `persons.xml` (if present) off an + * ALREADY-OPEN `zip` (owned and closed by the caller, `loadMutateSerializeXlsx` + * — kept open across load+mutate+serialize so `serializeArchiveToFile` can + * still reach every OTHER entry directly for streaming, see that function's + * own header). Whole-archive size-guarded before anything is read + * (`checkAllEntriesWithinCeiling`, metadata-only, matching this module's own + * read-path convention). Review F1 (High, android-xlsx-review): this no + * longer eagerly decompresses EVERY entry into a byte map — only these few + * named top-level parts, via `readAndTrackPart`. Worksheet-scoped parts stay + * exactly as lazy as they already were (`getWorksheetContext`, unchanged by + * this fix). */ +private fun loadArchiveForWrite(zip: ZipFile): XlsxWriteResult { + return try { + if (checkAllEntriesWithinCeiling(zip) is ZipSizeGuardResult.ArchiveTooLarge) { + return XlsxWriteResult.Err(XlsxWriteError.ARCHIVE_TOO_LARGE) + } + if (zip.getEntry("xl/workbook.xml") == null || zip.getEntry("[Content_Types].xml") == null) { + return XlsxWriteResult.Err(XlsxWriteError.INVALID_XLSX) + } + + val originalBytesByName = mutableMapOf() + val workbookXml = readAndTrackPart(zip, "xl/workbook.xml", originalBytesByName) ?: return XlsxWriteResult.Err(XlsxWriteError.INVALID_XLSX) + val contentTypesXml = readAndTrackPart(zip, "[Content_Types].xml", originalBytesByName) ?: return XlsxWriteResult.Err(XlsxWriteError.INVALID_XLSX) + val workbookRelsXml = readAndTrackPart(zip, "xl/_rels/workbook.xml.rels", originalBytesByName) ?: EMPTY_RELS_XML + + val sheets = parseSheetsFromWorkbook(workbookXml, workbookRelsXml) + val workbookRelsDoc = parseXml(workbookRelsXml) + val contentTypesDoc = parseXml(contentTypesXml) + + var personsPartPath = "xl/persons/person.xml" + var personsDoc: Document? = null + var personsOriginal: String? = null + val personRel = elementsByTag(workbookRelsDoc, "Relationship").find { it.getAttribute("Type") == PERSON_REL_TYPE } + if (personRel != null) { + personsPartPath = resolveRelTarget("xl", personRel.getAttribute("Target")) + personsOriginal = readAndTrackPart(zip, personsPartPath, originalBytesByName) + if (personsOriginal != null) personsDoc = parseXml(personsOriginal) + } + + XlsxWriteResult.Ok( + WriteArchive( + zip = zip, + originalBytesByName = originalBytesByName, + sheets = sheets, + contentTypesDoc = contentTypesDoc, + contentTypesOriginal = contentTypesXml, + workbookRelsDoc = workbookRelsDoc, + workbookRelsOriginal = workbookRelsXml, + personsPartPath = personsPartPath, + personsDoc = personsDoc, + personsOriginal = personsOriginal, + ), + ) + } catch (_: XlsxUnsafeXmlDoctypeException) { + XlsxWriteResult.Err(XlsxWriteError.UNSAFE_XML) + } catch (_: ZipBombDetectedException) { + XlsxWriteResult.Err(XlsxWriteError.ARCHIVE_TOO_LARGE) + } +} + +// The 200MB ARCHIVE ceiling (DocCommentsZipSizeGuard.kt's declared-size +// checks), applied to real bytes as a STREAMED copy of an untouched entry +// runs. Deliberately higher than `MAX_IN_MEMORY_PART_BYTES` (32MB, the limit +// for a part `readEntryBounded` loads into memory): a streamed copy only ever +// holds one small buffer, so a large image or untouched sheet is fine here. +private const val MAX_STREAMED_ENTRY_BYTES = 200L * 1024 * 1024 +private const val STREAM_COPY_BUFFER_BYTES = 8192 + +/** Review F1 (High, android-xlsx-review) fix: streams ONE untouched zip entry + * straight from `sourceZip` to `zos`, in bounded chunks, NEVER buffering its + * whole decompressed content in one array — the replacement for the old + * `WriteArchive.entries` map, which used to `readEntryBounded` (fully + * decompress into a `ByteArray`) EVERY entry in the archive regardless of + * whether the mutation ever touched it, then re-deflate every one of them + * again on the way out. + * + * A `STORED` entry (already uncompressed — no expansion possible, and its + * declared size already passed `checkAllEntriesWithinCeiling`) is passed + * through as RAW bytes with its OWN size/crc preserved exactly — "raw + * compressed bytes where java.util.zip allows" (a `STORED` entry's + * "compressed" form IS its raw content, so no inflate/deflate work happens + * at all). Anything else (`DEFLATED`, the overwhelming majority of real + * entries) has no public `java.util.zip` API to copy its compressed byte + * range verbatim (confirmed against the JDK's own public surface — desktop's + * JSZip-based byte-for-byte passthrough has no Kotlin/JDK equivalent), so it + * is decompressed and re-compressed at the output stream's own default + * level — but STREAMED through one small fixed buffer, never held in memory + * as a whole `ByteArray`, with the SAME real-byte-counted zip-bomb backstop + * `readEntryBounded` gives every part this module actually parses. */ +private fun streamCopyUntouchedEntry(sourceZip: ZipFile, entry: ZipEntry, zos: ZipOutputStream) { + val isStored = entry.method == ZipEntry.STORED + val outEntry = ZipEntry(entry.name) + if (isStored) { + outEntry.method = ZipEntry.STORED + outEntry.size = entry.size + outEntry.compressedSize = entry.size + outEntry.crc = entry.crc + } + zos.putNextEntry(outEntry) + sourceZip.getInputStream(entry).use { input -> + val buffer = ByteArray(STREAM_COPY_BUFFER_BYTES) + var total = 0L + while (true) { + val n = input.read(buffer) + if (n == -1) break + total += n + // STORED can never expand past its own already-checked declared + // size — only a DEFLATED-or-other entry needs this backstop. + if (!isStored && total > MAX_STREAMED_ENTRY_BYTES) throw ZipBombDetectedException() + zos.write(buffer, 0, n) + } + } + zos.closeEntry() +} + +/** Writes `archive` to `outFile` (a scratch path). Every part this operation + * actually CHANGED (or minted fresh) is (re-)serialized into a small local + * `overrides` map; every part it merely TOUCHED but left unchanged is + * re-emitted from `archive.originalBytesByName` verbatim; every OTHER entry + * in `archive.zip` — genuinely untouched by this call — is streamed straight + * through via `streamCopyUntouchedEntry`, never decompressed into a + * `ByteArray` here. Review F1 (High): this is the fix — the prior version of + * this function iterated a `LinkedHashMap` that ALREADY held every entry's + * full decompressed bytes, which is what actually did the "at most six parts + * touched" guarantee this file's own doc comments claimed but did not + * deliver at the compressed-byte level. */ +private fun serializeArchiveToFile(archive: WriteArchive, outFile: File) { + val overrides = mutableMapOf() + if (archive.contentTypesChanged) { + overrides["[Content_Types].xml"] = serializeXlsxPart(archive.contentTypesDoc, archive.contentTypesOriginal).toByteArray(Charsets.UTF_8) + } + if (archive.workbookRelsChanged) { + overrides["xl/_rels/workbook.xml.rels"] = serializeXlsxPart(archive.workbookRelsDoc, archive.workbookRelsOriginal).toByteArray(Charsets.UTF_8) + } + if (archive.personsChanged && archive.personsDoc != null) { + overrides[archive.personsPartPath] = serializeXlsxPart(archive.personsDoc!!, archive.personsOriginal).toByteArray(Charsets.UTF_8) + } + for (ctx in archive.worksheetContexts.values) { + if (ctx.worksheetChanged) { + overrides[ctx.partPath] = serializeXlsxPart(ctx.worksheetDoc, ctx.worksheetXmlOriginal).toByteArray(Charsets.UTF_8) + } + if (ctx.relsChanged) { + overrides[ctx.relsPartPath] = serializeXlsxPart(ctx.relsDoc, ctx.relsXmlOriginal).toByteArray(Charsets.UTF_8) + } + val commentsPath = ctx.commentsPartPath + if (ctx.commentsChanged && ctx.commentsDoc != null && commentsPath != null) { + overrides[commentsPath] = serializeXlsxPart(ctx.commentsDoc!!, ctx.commentsXmlOriginal).toByteArray(Charsets.UTF_8) + } + val vmlPath = ctx.vmlPartPath + if (ctx.vmlChanged && ctx.vmlDoc != null && vmlPath != null) { + overrides[vmlPath] = serializeXlsxPart(ctx.vmlDoc!!, ctx.vmlXmlOriginal).toByteArray(Charsets.UTF_8) + } + val threadedPath = ctx.threadedPartPath + if (ctx.threadedChanged && ctx.threadedDoc != null && threadedPath != null) { + overrides[threadedPath] = serializeXlsxPart(ctx.threadedDoc!!, ctx.threadedXmlOriginal).toByteArray(Charsets.UTF_8) + } + } + + ZipOutputStream(BufferedOutputStream(FileOutputStream(outFile))).use { zos -> + val writtenNames = mutableSetOf() + for (entry in Collections.list(archive.zip.entries())) { + if (entry.isDirectory) continue + val name = entry.name + // Edit/delete build: a part `cleanupEmptyCommentPartsIfNeeded` + // removed outright is skipped here entirely — never re-emitted + // from `overrides` or the original archive, the Kotlin-side + // equivalent of desktop's `zip.remove(path)`. + if (name in archive.removedPartNames) continue + writtenNames.add(name) + val overrideBytes = overrides[name] + when { + overrideBytes != null -> zos.writeXlsxEntry(name, overrideBytes) + archive.originalBytesByName.containsKey(name) -> zos.writeXlsxEntry(name, archive.originalBytesByName.getValue(name)) + else -> streamCopyUntouchedEntry(archive.zip, entry, zos) + } + } + for ((name, bytes) in overrides) { + if (name !in writtenNames && name !in archive.removedPartNames) zos.writeXlsxEntry(name, bytes) // a brand-new part + } + } +} + +/** Glues the generic file-in/file-out pipeline (`writeXlsxMutation`) to the + * archive-based mutations: opens `workCopy` as a `ZipFile` ONCE and keeps it + * open across load, mutate, AND serialize (`finally { zip.close() }`) — the + * structural change review F1 needed, since `serializeArchiveToFile` must be + * able to stream-copy an untouched entry straight from the SAME open source + * archive `loadArchiveForWrite`/`getWorksheetContext` read the touched parts + * from. A `ZipBombDetectedException`/`XlsxUnsafeXmlDoctypeException` thrown + * during serialize (the streamed backstop firing on an untouched entry, or a + * DOCTYPE surfacing in a part this call newly decompresses) is RE-THROWN + * past this function's own generic `WRITE_FAILED` catch, the same + * "never swallow a typed signal into a wrong wire code" idiom this file + * already uses elsewhere — `writeXlsxMutation`'s own outer catch is what + * turns those into the correctly-typed `UNSAFE_XML`/`ARCHIVE_TOO_LARGE`. */ +private fun loadMutateSerializeXlsx(workCopy: File, outFile: File, mutateArchive: (WriteArchive) -> XlsxWriteResult): XlsxWriteResult { + val zip = try { + ZipFile(workCopy) + } catch (_: Exception) { + return XlsxWriteResult.Err(XlsxWriteError.INVALID_XLSX) + } + return try { + val loaded = loadArchiveForWrite(zip) + if (loaded is XlsxWriteResult.Err) return loaded + val archive = (loaded as XlsxWriteResult.Ok).value + val mutated = mutateArchive(archive) + if (mutated is XlsxWriteResult.Err) return mutated + val value = (mutated as XlsxWriteResult.Ok).value + try { + serializeArchiveToFile(archive, outFile) + XlsxWriteResult.Ok(value) + } catch (e: XlsxUnsafeXmlDoctypeException) { + throw e + } catch (e: ZipBombDetectedException) { + throw e + } catch (_: Exception) { + XlsxWriteResult.Err(XlsxWriteError.WRITE_FAILED) + } + } finally { + zip.close() + } +} + +// ----------------------------------------------------------------------- +// Pure, in-memory mutations against an already-loaded archive. +// ----------------------------------------------------------------------- + +private fun mutateAddXlsxComment(archive: WriteArchive, selector: CommentSelector, text: String, author: CommentAuthor): XlsxWriteResult { + if (selector !is CommentSelector.Cell) return XlsxWriteResult.Err(XlsxWriteError.INVALID_SELECTOR) + val safeText = stripIllegalXmlChars(text) + val wsResult = resolveWorksheetForSelector(archive, selector.selector) + if (wsResult is XlsxWriteResult.Err) return wsResult + val ctx = (wsResult as XlsxWriteResult.Ok).value + val cellAddr = selector.selector.cell + + val threadedDoc = ctx.threadedDoc + if (threadedDoc != null && findAnyRootAtRef(threadedDoc, cellAddr) != null) { + return XlsxWriteResult.Err(XlsxWriteError.CELL_ALREADY_HAS_COMMENT) + } + val commentsDoc = ctx.commentsDoc + if (commentsDoc != null && findGenuineNoteAtCell(commentsDoc, cellAddr)) { + return XlsxWriteResult.Err(XlsxWriteError.CELL_HAS_NOTE) + } + + val personId = resolveOrCreatePerson(archive, author) + val snapshot = ThreadSnapshot( + id = mintGuid(), + personId = personId, + dT = formatThreadedDate(Date()), + done = false, + text = safeText, + replies = emptyList(), + ) + insertThreadIntoWorksheet(archive, ctx, cellAddr, snapshot) + return XlsxWriteResult.Ok(buildXlsxThreadId(ctx.sheetId, cellAddr, snapshot.id)) +} + +/** Enriches the response with the persisted `CommentReply` — a reply's own id + * can't be pre-computed by the caller the way a brand-new comment's can + * (§1.6), the same reasoning `DocxComments.kt`'s own `mutateReplyToComment` + * already acts on. */ +private fun mutateReplyToXlsxComment(archive: WriteArchive, id: String, text: String, author: CommentAuthor): XlsxWriteResult { + val safeText = stripIllegalXmlChars(text) + val found = resolveXlsxThreadTarget(archive, id) + if (found is XlsxWriteResult.Err) return found + val target = (found as XlsxWriteResult.Ok).value + val ctx = target.ctx + val rootEl = target.rootEl + val rootId = rootEl.getAttribute("id") + val ref = rootEl.getAttribute("ref").ifEmpty { target.cell } + val personId = resolveOrCreatePerson(archive, author) + val replyId = mintGuid() + val createdAtIso = formatThreadedDate(Date()) + appendThreadedElement(ctx.threadedDoc!!, ref, createdAtIso, personId, replyId, safeText, parentId = rootId) + ctx.threadedChanged = true + + val repliesSorted = repliesOfRoot(ctx.threadedDoc!!, rootId).sortedBy { parseThreadedDate(it.getAttribute("dT")) } + val ordinal = repliesSorted.indexOfFirst { normalizeGuid(it.getAttribute("id")) == normalizeGuid(replyId) } + 1 + val replyTexts = repliesSorted.map { textOfThreadedComment(it) } + + val commentEl = ctx.commentsDoc?.let { findTcComment(it, rootId) } + if (commentEl != null && ctx.commentsDoc != null) { + setXlsxCommentBody(ctx.commentsDoc!!, commentEl, buildPlaceholderBody(textOfThreadedComment(rootEl), replyTexts)) + ctx.commentsChanged = true + } + + val reply = CommentReply( + id = "${buildXlsxThreadId(ctx.sheetId, ref, rootId)}-r$ordinal", + author = author, + text = safeText, + createdAt = parseThreadedDate(createdAtIso), + ) + return XlsxWriteResult.Ok(reply) +} + +/** §4.2: `done` lives ONLY on the root element, and this app OMITS the + * attribute entirely on reopen (never writes `done="0"`) — never touching + * the legacy placeholder, which never reflects resolve state at all. */ +private fun mutateSetResolvedXlsx(archive: WriteArchive, id: String, done: Boolean): XlsxWriteResult { + val found = resolveXlsxThreadTarget(archive, id) + if (found is XlsxWriteResult.Err) return found + val target = (found as XlsxWriteResult.Ok).value + if (done) target.rootEl.setAttribute("done", "1") else target.rootEl.removeAttribute("done") + target.ctx.threadedChanged = true + return XlsxWriteResult.Ok(Unit) +} + +private fun mutateMoveXlsxComment(archive: WriteArchive, id: String, newSelector: CommentSelector): XlsxWriteResult { + if (newSelector !is CommentSelector.Cell) return XlsxWriteResult.Err(XlsxWriteError.INVALID_SELECTOR) + val found = resolveXlsxThreadTarget(archive, id) + if (found is XlsxWriteResult.Err) return found + val target = (found as XlsxWriteResult.Ok).value + val oldCtx = target.ctx + val oldCell = target.cell + // §4.3a: read the OLD cell's thread data VERBATIM before touching + // anything — reused, never re-minted, at the new location. + val snapshot = snapshotThread(oldCtx, target.rootEl) + + val wsResult = resolveWorksheetForSelector(archive, newSelector.selector) + if (wsResult is XlsxWriteResult.Err) return wsResult + val newCtx = (wsResult as XlsxWriteResult.Ok).value + val newCell = newSelector.selector.cell + + val isSameCell = newCtx.partPath == oldCtx.partPath && newCell == oldCell + val newThreadedDoc = newCtx.threadedDoc + if (!isSameCell && newThreadedDoc != null && findAnyRootAtRef(newThreadedDoc, newCell) != null) { + return XlsxWriteResult.Err(XlsxWriteError.DESTINATION_CELL_OCCUPIED) + } + val newCommentsDoc = newCtx.commentsDoc + if (!isSameCell && newCommentsDoc != null && findGenuineNoteAtCell(newCommentsDoc, newCell)) { + return XlsxWriteResult.Err(XlsxWriteError.CELL_HAS_NOTE) + } + + removeThreadFromWorksheet(oldCtx, oldCell, target.rootEl) + insertThreadIntoWorksheet(archive, newCtx, newCell, snapshot) + + // Review F3 (Medium) partial fix, ported: a Move never changes the + // thread's own GUID, but it DOES change the id's embedded cell hint — + // returning the FRESH id lets a caller skip the fallback scan on its own + // next call. + return XlsxWriteResult.Ok(buildXlsxThreadId(newCtx.sheetId, newCell, snapshot.id)) +} + +/** Edit build (2026-09-28): overwrites the ROOT thread's own ``, then + * rebuilds the legacy placeholder from the thread's current transcript + * (the placeholder is always rebuilt whole, never patched). Mirrors + * `mutateEditXlsxComment` (xlsx-comments.ts). */ +private fun mutateEditXlsxComment(archive: WriteArchive, id: String, rawText: String): XlsxWriteResult { + val text = stripIllegalXmlChars(rawText) + val found = resolveXlsxThreadTarget(archive, id) + if (found is XlsxWriteResult.Err) return found + val target = (found as XlsxWriteResult.Ok).value + setThreadedCommentText(target.ctx.threadedDoc!!, target.rootEl, text) + target.ctx.threadedChanged = true + rebuildPlaceholderForRoot(target.ctx, target.rootEl) + return XlsxWriteResult.Ok(text) +} + +/** Edit build: `id` names the thread's root (matches every other + * reply/resolve/reopen/move call shape); `replyId`'s trailing `-r{n}` + * (`replyOrdinalFromId`) selects which reply within it, sorted by `dT` the + * SAME way the read path assigns ordinals. Mirrors `mutateEditXlsxReply` + * (xlsx-comments.ts). */ +private fun mutateEditXlsxReply(archive: WriteArchive, id: String, replyId: String, rawText: String): XlsxWriteResult { + val text = stripIllegalXmlChars(rawText) + val ordinal = replyOrdinalFromId(replyId) ?: return XlsxWriteResult.Err(XlsxWriteError.COMMENT_NOT_FOUND) + val found = resolveXlsxThreadTarget(archive, id) + if (found is XlsxWriteResult.Err) return found + val target = (found as XlsxWriteResult.Ok).value + val ctx = target.ctx + val rootId = target.rootEl.getAttribute("id") + val repliesSorted = repliesOfRoot(ctx.threadedDoc!!, rootId).sortedBy { parseThreadedDate(it.getAttribute("dT")) } + val replyEl = repliesSorted.getOrNull(ordinal - 1) ?: return XlsxWriteResult.Err(XlsxWriteError.COMMENT_NOT_FOUND) + setThreadedCommentText(ctx.threadedDoc!!, replyEl, text) + ctx.threadedChanged = true + rebuildPlaceholderForRoot(ctx, target.rootEl) + val personId = replyEl.getAttribute("personId") + val reply = CommentReply( + id = replyId, + author = toCommentAuthor(personDisplayName(archive, personId)), + text = text, + createdAt = parseThreadedDate(replyEl.getAttribute("dT")), + ) + return XlsxWriteResult.Ok(reply) +} + +/** Delete build: removes the root + every reply, its legacy placeholder and + * VML shape (`removeThreadFromWorksheet`, already built for Move), then — + * new here — cleans up the worksheet's own now-empty comment parts if this + * was its last comment of any kind. Mirrors `mutateDeleteXlsxComment` + * (xlsx-comments.ts). */ +private fun mutateDeleteXlsxComment(archive: WriteArchive, id: String): XlsxWriteResult { + val found = resolveXlsxThreadTarget(archive, id) + if (found is XlsxWriteResult.Err) return found + val target = (found as XlsxWriteResult.Ok).value + removeThreadFromWorksheet(target.ctx, target.cell, target.rootEl) + cleanupEmptyCommentPartsIfNeeded(archive, target.ctx) + return XlsxWriteResult.Ok(Unit) +} + +/** Delete build: removes ONE reply and rebuilds the placeholder from what's + * left — never touches the root, other replies, or the VML shape/legacy + * ``'s own existence (the thread itself still has its root). + * Mirrors `mutateDeleteXlsxReply` (xlsx-comments.ts). */ +private fun mutateDeleteXlsxReply(archive: WriteArchive, id: String, replyId: String): XlsxWriteResult { + val ordinal = replyOrdinalFromId(replyId) ?: return XlsxWriteResult.Err(XlsxWriteError.COMMENT_NOT_FOUND) + val found = resolveXlsxThreadTarget(archive, id) + if (found is XlsxWriteResult.Err) return found + val target = (found as XlsxWriteResult.Ok).value + val ctx = target.ctx + val rootId = target.rootEl.getAttribute("id") + val repliesSorted = repliesOfRoot(ctx.threadedDoc!!, rootId).sortedBy { parseThreadedDate(it.getAttribute("dT")) } + val replyEl = repliesSorted.getOrNull(ordinal - 1) ?: return XlsxWriteResult.Err(XlsxWriteError.COMMENT_NOT_FOUND) + replyEl.parentNode?.removeChild(replyEl) + ctx.threadedChanged = true + rebuildPlaceholderForRoot(ctx, target.rootEl) + return XlsxWriteResult.Ok(Unit) +} + +private fun findByThreadIdPrefix(comments: List, id: String): PersistedComment? { + val parsed = parseXlsxThreadId(id) ?: return null + val guidNorm = normalizeGuid(parsed.guid) + return comments.find { + val p = parseXlsxThreadId(it.id) + p != null && normalizeGuid(p.guid) == guidNorm + } +} + +// ----------------------------------------------------------------------- +// The write pipeline: lock, scratch-copy, mutate, verify-then-replace, +// backup, atomic move — mirrors `DocxComments.kt`'s own `writeDocxMutation` +// exactly (this module's own §4.3/§4.3a "mirror §3.3's steps" instruction). +// ----------------------------------------------------------------------- + +private val xlsxWriteLocks = ConcurrentHashMap() +private fun xlsxLockFor(absolutePath: String): Mutex = xlsxWriteLocks.computeIfAbsent(absolutePath) { Mutex() } + +const val XLSX_BACKUP_SUFFIX = ".xlsx.bak" + +/** Same rolling-backup-per-file convention as `DocxComments.kt`'s own + * `docxBackupPathFor` — a hash of the SOURCE file's absolute path, never a + * timestamp, so repeated writes to the same file overwrite the previous + * backup rather than accumulating one per write forever. */ +fun xlsxBackupPathFor(homeDir: File, absolutePath: String): File { + val hash = sha256Hex(absolutePath) + val dir = File(File(homeDir, ".claude"), "youcoded-doc-backups") + return File(dir, "$hash$XLSX_BACKUP_SUFFIX") +} + +private fun defaultXlsxFsyncFile(file: File) { + FileChannel.open(file.toPath(), StandardOpenOption.READ, StandardOpenOption.WRITE).use { it.force(true) } +} + +private fun bestEffortFsyncXlsxDir(dir: File) { + try { + FileChannel.open(dir.toPath(), StandardOpenOption.READ).use { it.force(true) } + } catch (_: Exception) { + // Not fatal — see DocxComments.kt's identical comment. + } +} + +/** + * The generic half of the pipeline — `mutate`/`verify` operate on FILES, + * never a pre-parsed archive, the same shape `DocxComments.kt`'s own + * `writeDocxMutation` uses (and, not coincidentally, so a test can inject a + * fault the same simple way). `internal` (not `private`): a pinning test for + * "failed verification leaves the target byte-identical" calls this + * directly. Step 0 (design §3.3/§4.3, review round 3 F4) reuses + * `DocxComments.kt`'s own `isFileOpenElsewhere` — the SAME plain + * sibling-file check, `internal` in that file and therefore visible here + * (same module/package) — rather than a second copy. + */ +internal suspend fun writeXlsxMutation( + absolutePath: String, + homeDir: File, + mutate: (workCopy: File, outFile: File) -> XlsxWriteResult, + verify: (outFile: File, value: T, originalFile: File) -> Boolean, + syncFile: (File) -> Unit = ::defaultXlsxFsyncFile, +): XlsxWriteResult { + return xlsxLockFor(absolutePath).withLock { + val target = File(absolutePath) + // Step 0 — the VERY FIRST thing this pipeline does, before backup. + if (isFileOpenElsewhere(target)) return@withLock XlsxWriteResult.Err(XlsxWriteError.FILE_OPEN_ELSEWHERE) + if (!target.exists()) return@withLock XlsxWriteResult.Err(XlsxWriteError.READ_FAILED) + val parentDir = target.parentFile ?: return@withLock XlsxWriteResult.Err(XlsxWriteError.READ_FAILED) + val workCopy = File(parentDir, "${target.name}.ycdreadxlsx.tmp") + val outFile = File(parentDir, "${target.name}.ycdwritexlsx.tmp") + try { + try { + target.copyTo(workCopy, overwrite = true) + } catch (_: Exception) { + return@withLock XlsxWriteResult.Err(XlsxWriteError.READ_FAILED) + } + + // A corrupt-but-openable part (an unexpected DOCTYPE, or a + // zip-bomb caught only mid-parse rather than at the archive-wide + // pre-scan) can throw past `mutate`'s own internal try/catch + // boundaries — those exceptions are typed and specific to this + // module (`XlsxUnsafeXmlDoctypeException`/`ZipBombDetectedException`), + // so they're caught HERE, at the one place this pipeline actually + // returns a typed `XlsxWriteResult`, mirroring the boundary + // `DocCommentsDispatch.kt`'s own `nativeMutateExceptionBoundary` + // already applies one layer further out for anything else. + val mutated = try { + mutate(workCopy, outFile) + } catch (_: XlsxUnsafeXmlDoctypeException) { + XlsxWriteResult.Err(XlsxWriteError.UNSAFE_XML) + } catch (_: ZipBombDetectedException) { + XlsxWriteResult.Err(XlsxWriteError.ARCHIVE_TOO_LARGE) + } + if (mutated is XlsxWriteResult.Err) return@withLock mutated + val value = (mutated as XlsxWriteResult.Ok).value + + // Verify-by-reread BEFORE the real target is ever touched. + // xmlPartsStayWellFormed: the strict namespace check the lenient DOM + // can't do (DocCommentsXmlSafety.kt, 2026-09-28 PR review). + val verified = try { xmlPartsStayWellFormed(outFile, target) && verify(outFile, value, target) } catch (_: Exception) { false } + if (!verified) return@withLock XlsxWriteResult.Err(XlsxWriteError.VERIFY_FAILED) + + try { + syncFile(outFile) + } catch (_: Exception) { + return@withLock XlsxWriteResult.Err(XlsxWriteError.WRITE_FAILED) + } + + val backupFile = xlsxBackupPathFor(homeDir, absolutePath) + try { + backupFile.parentFile?.mkdirs() + target.copyTo(backupFile, overwrite = true) + } catch (_: Exception) { + return@withLock XlsxWriteResult.Err(XlsxWriteError.BACKUP_FAILED) + } + + try { + try { + Files.move(outFile.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: java.nio.file.AtomicMoveNotSupportedException) { + Files.move(outFile.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } catch (_: Exception) { + return@withLock XlsxWriteResult.Err(XlsxWriteError.WRITE_FAILED) + } + bestEffortFsyncXlsxDir(parentDir) + XlsxWriteResult.Ok(value) + } finally { + workCopy.delete() + outFile.delete() + } + } +} + +// ----------------------------------------------------------------------- +// Read (T18). +// ----------------------------------------------------------------------- + +private fun readZipEntryText(zip: ZipFile, name: String): String? { + val entry = zip.getEntry(name) ?: return null + return readEntryBounded(zip, entry).toString(Charsets.UTF_8) +} + +/** + * Reads every Excel THREADED comment out of an `.xlsx` file on disk into + * `PersistedComment`-shaped thread records — the SAME shape desktop's + * `readXlsxComments` (T12) produces, field-for-field, per this task's own + * golden-fixture parity test. `path` is stamped onto every returned record. + * + * A workbook with ONLY genuine Notes (no threaded comments at all) returns + * an EMPTY list — never the garbled pseudo-comment the retired reader + * produced for this exact shape (§4.1). Never throws on "nothing to read." + */ +fun readXlsxComments(file: File, path: String): XlsxReadResult { + val zip: ZipFile + try { + zip = ZipFile(file) + } catch (_: Exception) { + return XlsxReadResult.Err(XlsxReadError.INVALID_XLSX) + } + return try { + zip.use { z -> readXlsxCommentsFromZip(z, path) } + } catch (_: XlsxUnsafeXmlDoctypeException) { + XlsxReadResult.Err(XlsxReadError.UNSAFE_XML) + } catch (_: ZipBombDetectedException) { + XlsxReadResult.Err(XlsxReadError.ARCHIVE_TOO_LARGE) + } +} + +private fun readXlsxCommentsFromZip(z: ZipFile, path: String): XlsxReadResult { + // Checked FIRST, before any entry is decompressed — see + // DocCommentsZipSizeGuard.kt's own header for why this module uses a + // whole-archive pre-scan rather than desktop's narrower named-parts + // check (an xlsx's part set/count varies per workbook). + if (checkAllEntriesWithinCeiling(z) is ZipSizeGuardResult.ArchiveTooLarge) { + return XlsxReadResult.Err(XlsxReadError.ARCHIVE_TOO_LARGE) + } + + val workbookXml = readZipEntryText(z, "xl/workbook.xml") ?: return XlsxReadResult.Err(XlsxReadError.INVALID_XLSX) + val workbookRelsXml = readZipEntryText(z, "xl/_rels/workbook.xml.rels") ?: EMPTY_RELS_XML + + val sheets = try { + parseSheetsFromWorkbook(workbookXml, workbookRelsXml) + } catch (e: XlsxUnsafeXmlDoctypeException) { + throw e + } catch (_: Exception) { + return XlsxReadResult.Err(XlsxReadError.INVALID_XLSX) + } + val realSheets = sheets.filter { !it.isChartsheet } + val singleSheet = realSheets.size <= 1 + + // Person map, resolved once at workbook level (§4.2). + val personMap = HashMap() + val workbookRelsDoc = try { + parseXml(workbookRelsXml) + } catch (e: XlsxUnsafeXmlDoctypeException) { + throw e + } catch (_: Exception) { + null + } + val personRel = workbookRelsDoc?.let { doc -> elementsByTag(doc, "Relationship").find { it.getAttribute("Type") == PERSON_REL_TYPE } } + if (personRel != null) { + val personPath = resolveRelTarget("xl", personRel.getAttribute("Target")) + val personXml = readZipEntryText(z, personPath) + if (personXml != null) { + val personDoc = try { + parseXml(personXml) + } catch (e: XlsxUnsafeXmlDoctypeException) { + throw e + } catch (_: Exception) { + null + } + if (personDoc != null) { + for (el in elementsByLocalName(personDoc, "person")) { + val id = el.getAttribute("id") + if (id.isNotEmpty()) personMap[normalizeGuid(id)] = el.getAttribute("displayName") + } + } + } + } + + var totalRecords = 0 + val comments = mutableListOf() + + for (sheetMeta in realSheets) { + val relsPath = worksheetRelsPathFor(sheetMeta.partPath) + val relsXml = readZipEntryText(z, relsPath) ?: continue + val relsDoc = try { + parseXml(relsXml) + } catch (e: XlsxUnsafeXmlDoctypeException) { + throw e + } catch (_: Exception) { + continue + } + val threadedRel = elementsByTag(relsDoc, "Relationship").find { it.getAttribute("Type") == THREADED_COMMENT_REL_TYPE } ?: continue + val threadedPath = resolveRelTarget(dirnameOfPart(sheetMeta.partPath), threadedRel.getAttribute("Target")) + val threadedXml = readZipEntryText(z, threadedPath) ?: continue // dangling relationship — skip this sheet + + val threadedDoc = try { + parseXml(threadedXml) + } catch (e: XlsxUnsafeXmlDoctypeException) { + throw e + } catch (_: Exception) { + continue + } + val all = elementsByLocalName(threadedDoc, "threadedComment") + totalRecords += all.size + if (totalRecords > MAX_COMMENT_RECORDS) return XlsxReadResult.Err(XlsxReadError.TOO_MANY_COMMENTS) + + val repliesByRoot = HashMap>() + val roots = mutableListOf() + for (el in all) { + val parentId = el.getAttribute("parentId") + if (parentId.isNotEmpty()) { + repliesByRoot.getOrPut(normalizeGuid(parentId)) { mutableListOf() }.add(el) + } else { + roots.add(el) + } + } + + for (rootEl in roots) { + val rootId = rootEl.getAttribute("id") + val ref = rootEl.getAttribute("ref") + val personId = rootEl.getAttribute("personId") + val dT = rootEl.getAttribute("dT") + val resolved = rootEl.getAttribute("done") == "1" + val text = textOfThreadedComment(rootEl) + val replyEls = (repliesByRoot[normalizeGuid(rootId)] ?: emptyList()).sortedBy { parseThreadedDate(it.getAttribute("dT")) } + + val appId = buildXlsxThreadId(sheetMeta.sheetId, ref, rootId) + val replies = replyEls.mapIndexed { i, r -> + CommentReply( + id = "$appId-r${i + 1}", + author = toCommentAuthor(personMap[normalizeGuid(r.getAttribute("personId"))] ?: "Unknown"), + text = textOfThreadedComment(r), + createdAt = parseThreadedDate(r.getAttribute("dT")), + ) + } + + val cellSelector = CellSelector(cell = ref, sheet = if (singleSheet) null else sheetMeta.name) + comments.add( + PersistedComment( + id = appId, + path = path, + selector = CommentSelector.Cell(cellSelector), + text = text, + author = toCommentAuthor(personMap[normalizeGuid(personId)] ?: "Unknown"), + createdAt = parseThreadedDate(dT), + replies = replies, + resolved = resolved, + // A threaded comment's OOXML has no separate resolve/reopen + // AUDIT TRAIL — only the current `done` bit. + history = emptyList(), + ), + ) + } + } + + return XlsxReadResult.Ok(comments) +} + +// ----------------------------------------------------------------------- +// Public write API (T19) — one per operation, each wiring its own mutate + +// verify into `writeXlsxMutation`. `absolutePath` is the already- +// containment-verified real file path (`DocCommentsDispatch.kt` resolves +// it); `path` is the caller's project-relative (or fallback-absolute) path, +// stamped onto `PersistedComment.path` — needed here only to re-run this +// file's own reader during verification. +// ----------------------------------------------------------------------- + +suspend fun addXlsxComment( + absolutePath: String, + path: String, + selector: CommentSelector, + text: String, + author: CommentAuthor, + homeDir: File, +): XlsxWriteResult = writeXlsxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerializeXlsx(workCopy, outFile) { archive -> mutateAddXlsxComment(archive, selector, text, author) } }, + verify = { outFile, newId, _ -> + val r = readXlsxComments(outFile, path) + (r as? XlsxReadResult.Ok)?.comments?.any { it.id == newId && it.text == stripIllegalXmlChars(text) && it.replies.isEmpty() } == true + }, +) + +suspend fun replyToXlsxComment( + absolutePath: String, + path: String, + id: String, + text: String, + author: CommentAuthor, + homeDir: File, +): XlsxWriteResult = writeXlsxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerializeXlsx(workCopy, outFile) { archive -> mutateReplyToXlsxComment(archive, id, text, author) } }, + verify = { outFile, reply, _ -> + val r = readXlsxComments(outFile, path) + val target = (r as? XlsxReadResult.Ok)?.comments?.let { findByThreadIdPrefix(it, id) } + target?.replies?.any { it.id == reply.id && it.text == reply.text } == true + }, +) + +/** `by` accepted for call-site symmetry with the generic `{path, id, by}` + * payload but deliberately UNUSED — a threaded comment's resolve/reopen has + * no separate audit trail, only the current `done` bit. */ +suspend fun resolveXlsxComment(absolutePath: String, path: String, id: String, homeDir: File): XlsxWriteResult = writeXlsxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerializeXlsx(workCopy, outFile) { archive -> mutateSetResolvedXlsx(archive, id, true) } }, + verify = { outFile, _, _ -> + val r = readXlsxComments(outFile, path) + (r as? XlsxReadResult.Ok)?.comments?.let { findByThreadIdPrefix(it, id) }?.resolved == true + }, +) + +suspend fun reopenXlsxComment(absolutePath: String, path: String, id: String, homeDir: File): XlsxWriteResult = writeXlsxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerializeXlsx(workCopy, outFile) { archive -> mutateSetResolvedXlsx(archive, id, false) } }, + verify = { outFile, _, _ -> + val r = readXlsxComments(outFile, path) + (r as? XlsxReadResult.Ok)?.comments?.let { findByThreadIdPrefix(it, id) }?.resolved == false + }, +) + +/** Returns the moved thread's FRESH id (embedding the new cell) so a caller + * that keeps it avoids paying for the full-workbook fallback scan on its + * very next call. */ +suspend fun moveXlsxComment( + absolutePath: String, + path: String, + id: String, + newSelector: CommentSelector, + homeDir: File, +): XlsxWriteResult = writeXlsxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerializeXlsx(workCopy, outFile) { archive -> mutateMoveXlsxComment(archive, id, newSelector) } }, + verify = { outFile, newId, _ -> + if (newSelector !is CommentSelector.Cell) { + false + } else { + val r = readXlsxComments(outFile, path) + val list = (r as? XlsxReadResult.Ok)?.comments + val target = list?.let { l -> l.find { it.id == newId } ?: findByThreadIdPrefix(l, id) } + val sel = target?.selector + if (sel is CommentSelector.Cell) { + sel.selector.cell == newSelector.selector.cell && (sel.selector.sheet ?: "") == (newSelector.selector.sheet ?: "") + } else { + false + } + } + }, +) + +// ----------------------------------------------------------------------- +// Edit/delete build (2026-09-28, design doc §"Edit and delete"). Same +// write-pipeline/verify shape as every mutation above. Mirrors +// xlsx-comments.ts's own "Edit/delete build" public-orchestration section. +// ----------------------------------------------------------------------- + +suspend fun editXlsxComment( + absolutePath: String, + path: String, + id: String, + text: String, + homeDir: File, +): XlsxWriteResult = writeXlsxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerializeXlsx(workCopy, outFile) { archive -> mutateEditXlsxComment(archive, id, text) } }, + verify = { outFile, extraText, _ -> + val r = readXlsxComments(outFile, path) + val target = (r as? XlsxReadResult.Ok)?.comments?.let { findByThreadIdPrefix(it, id) } + target?.text == extraText + }, +) + +suspend fun editXlsxReply( + absolutePath: String, + path: String, + id: String, + replyId: String, + text: String, + homeDir: File, +): XlsxWriteResult = writeXlsxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerializeXlsx(workCopy, outFile) { archive -> mutateEditXlsxReply(archive, id, replyId, text) } }, + verify = { outFile, reply, _ -> + val r = readXlsxComments(outFile, path) + val target = (r as? XlsxReadResult.Ok)?.comments?.let { findByThreadIdPrefix(it, id) } + target?.replies?.any { it.id == reply.id && it.text == reply.text } == true + }, +) + +suspend fun deleteXlsxComment(absolutePath: String, path: String, id: String, homeDir: File): XlsxWriteResult = writeXlsxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerializeXlsx(workCopy, outFile) { archive -> mutateDeleteXlsxComment(archive, id) } }, + verify = { outFile, _, _ -> + val r = readXlsxComments(outFile, path) + (r as? XlsxReadResult.Ok)?.comments?.let { findByThreadIdPrefix(it, id) } == null + }, +) + +suspend fun deleteXlsxReply( + absolutePath: String, + path: String, + id: String, + replyId: String, + homeDir: File, +): XlsxWriteResult = writeXlsxMutation( + absolutePath, + homeDir, + mutate = { workCopy, outFile -> loadMutateSerializeXlsx(workCopy, outFile) { archive -> mutateDeleteXlsxReply(archive, id, replyId) } }, + verify = { outFile, _, _ -> + val r = readXlsxComments(outFile, path) + val target = (r as? XlsxReadResult.Ok)?.comments?.let { findByThreadIdPrefix(it, id) } + target != null && target.replies.none { it.id == replyId } + }, +) diff --git a/app/src/main/kotlin/com/youcoded/app/parser/HookEvent.kt b/app/src/main/kotlin/com/youcoded/app/parser/HookEvent.kt index 29d47805e..bf62f8c4e 100644 --- a/app/src/main/kotlin/com/youcoded/app/parser/HookEvent.kt +++ b/app/src/main/kotlin/com/youcoded/app/parser/HookEvent.kt @@ -54,6 +54,17 @@ sealed class HookEvent { val toolInput: JSONObject, val permissionSuggestions: org.json.JSONArray?, val requestId: String, + /** Claude Code's OWN live permission mode ('default'|'acceptEdits'| + * 'bypassPermissions'|'plan'|'dontAsk'|'auto') — part of every hook + * payload's common base shape (desktop mirror: main.ts reads + * `event.payload.permission_mode`, docs/cc-dependencies.md's "hook + * payload permission_mode field" entry). Optional by the CLI's own + * schema — absent reads as null, which + * DocCommentsPermission.shouldAutoApproveDocComment treats as "don't + * know, don't approve." hook-relay-blocking.js forwards the CLI's + * whole hook JSON verbatim (only adding mobileSessionId/claudePid), + * so this field was already reaching EventBridge unread until now. */ + val permissionMode: String?, ) : HookEvent() /** Emitted when a held PermissionRequest ends without a delivered user @@ -133,6 +144,8 @@ sealed class HookEvent { permissionSuggestions = if (obj.has("permission_suggestions")) obj.optJSONArray("permission_suggestions") else null, requestId = obj.optString("_requestId", ""), + permissionMode = if (obj.has("permission_mode") && !obj.isNull("permission_mode")) + obj.optString("permission_mode") else null, ) else -> null } diff --git a/app/src/main/kotlin/com/youcoded/app/runtime/ClaudeCodeDocCommentsMcp.kt b/app/src/main/kotlin/com/youcoded/app/runtime/ClaudeCodeDocCommentsMcp.kt new file mode 100644 index 000000000..37d5d8c95 --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/runtime/ClaudeCodeDocCommentsMcp.kt @@ -0,0 +1,244 @@ +package com.youcoded.app.runtime + +import com.youcoded.app.doccomments.DocCommentsMcpNames +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.SecureRandom + +/** + * Attaching the six document-comment tools (docs/active/specs/2026-09-26-doc- + * comments-build-design.md §5, §9) to a Claude Code session on Android — T9c + * (Android byte-identical MCP asset) and half of T20 (deploy + wiring; the + * queue applier itself is DocCommentsPendingQueue.kt). + * + * Mirrors ClaudeCodeMcp.kt's SendUserLink deploy (itself the Android half of + * claude-code-mcp.ts), citing that as the precedent this task was scoped + * against (design §9.2, T9c row: "citing ClaudeCodeMcp.kt / claude-code-mcp.ts's + * existing SendUserLink deploy as precedent"). Two differences from that + * precedent, both load-bearing: + * + * 1. This server carries a per-session SECRET (`token`) and a per-session + * random `serverId` (adversarial review 2026-09-27, findings #1/#2, ported + * from claude-code-doc-comments-mcp.ts's own header) — SendUserLink has + * neither, so it can safely reuse ONE fixed on-disk path across every + * session. This deploy therefore gets its OWN per-DEPLOYMENT subdirectory, + * keyed on the freshly-minted `serverId` itself (T9c/T20 review, finding + * #2 fix — an EARLIER version of this file keyed the directory on the + * caller-supplied `mobileSessionId` instead, with a `?: "no-session-id"` + * fallback at the one call site: harmless today because that call site + * always supplies a real UUID, but a landmine identical in shape to the + * exact fixed-path collision desktop's own finding #2 fix closed — a + * future caller hitting the default would silently collide with any other + * session sharing that same placeholder string. Keying on `serverId` + * instead removes the caller's input from the uniqueness guarantee + * entirely: `serverId` is ALWAYS a fresh random value, generated inside + * this very function, so no caller mistake can ever reintroduce a fixed + * path — the same guarantee desktop's `deployClaudeCodeDocCommentsMcp` + * already has by construction). + * 2. Only `ReadFileComments` is allow-listed (`allowedTools` below) — the five + * mutation tools are deliberately NOT pre-approved; DocCommentsPermission.kt + * decides those per-call, by argument, through the SAME PermissionRequest + * hook path ManagedSession.kt already auto-approves other categories + * through (see that file's own header for the full reasoning, ported from + * permission-auto-approve.ts). + * + * The server source itself is the shared asset doc-comments-mcp.js, kept + * byte-identical to desktop's `DOC_COMMENTS_SERVER_JS` constant by a desktop- + * side parity test (claude-code-doc-comments-mcp.test.ts), the same shape as + * claude-code-mcp.test.ts's existing SendUserLink parity test. + * + * --- Cleanup (T9c/T20 review, finding #3) --- + * + * Every deployment's directory (config + token) is deleted by PtyBridge.stop() + * once that session ends (`Deployment.deployDir`, below) — a session that + * ends without calling stop() (a process kill, a crash) leaves its directory + * behind, so `sweepStaleDeploysOnce` below also wipes every OTHER pre-existing + * subdirectory the first time this object deploys anything in a fresh process + * — safe because no in-memory `Entry` from a previous process life could + * possibly still reference one of those directories (this app's queue state + * is all in-process; nothing persists it across a process restart). + */ +object ClaudeCodeDocCommentsMcp { + /** Asset filename, also the on-disk filename — must match the desktop + * parity test's ANDROID_ASSET path. */ + const val SERVER_FILE = "doc-comments-mcp.js" + + private const val CONFIG_FILE = "mcp-config.json" + + /** Must match YOUCODED_PROJECT_ROOT_ENV in desktop/src/shared/doc-comments-mcp.ts. */ + const val PROJECT_ROOT_ENV = "YOUCODED_PROJECT_ROOT" + + /** Must match YOUCODED_MCP_TOKEN_ENV in desktop/src/shared/doc-comments-mcp.ts. */ + const val TOKEN_ENV = "YOUCODED_MCP_TOKEN" + + private val random = SecureRandom() + + /** Guards the once-per-process leftover sweep (finding #3) — a plain, + * non-atomic flag is fine: `deploy()` only ever runs on the single PTY + * launch path, never concurrently with itself for a fresh process. */ + @Volatile private var sweptStaleDeploysThisProcess = false + + /** Test-only: re-arms the once-per-process sweep so a test can observe it + * firing against its own fresh temp directory, rather than only ever + * once across an entire shared-JVM test run. */ + fun resetSweepForTest() { + sweptStaleDeploysThisProcess = false + } + + private fun randomHex(byteCount: Int): String { + val bytes = ByteArray(byteCount) + random.nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + + /** + * Owner-only permissions (T9c/T20 review, finding #1 — cheap hardening). + * Mirrors Bootstrap.kt's own `syncGhTokenToNetrc` technique for `~/.netrc` + * (revoke-for-everyone, then grant-for-owner, via the two-arg + * `File.setReadable`/`setWritable`, never POSIX attribute views) rather + * than inventing a second technique for the same class of file. Extended + * with `setExecutable` for a directory, which needs its own `x` bit to + * stay traversable by its owning process at all. + * + * WHY this is real but narrow hardening, not a fix for finding #1's own + * threat (see the WHY note beside token generation, below, for the full + * reasoning): Android's per-UID app sandbox already denies every OTHER + * app's process regardless of this file's own mode bits, and this app's + * OWN Bash/Write tools run under the SAME uid this file is already + * "owner-only" for — unaffected either way. What this DOES close: a + * rooted device, an ADB `run-as` shell, or a backup/extraction tool that + * can read arbitrary files under this uid without going through Android's + * per-app permission model at all would otherwise see a + * world/group-readable file purely because nothing ever restricted it — + * cheap to close, worth closing, not the primary defense. + */ + private fun restrictToOwner(target: File, isDirectory: Boolean) { + target.setReadable(false, false) + target.setReadable(true, true) + target.setWritable(false, false) + target.setWritable(true, true) + if (isDirectory) { + target.setExecutable(false, false) + target.setExecutable(true, true) + } + } + + /** + * Finding #3: every directory this object has EVER deployed lives under + * one parent (`claude-code-doc-comments-mcp/`) with no per-deployment + * cleanup guaranteed (a crash or a process kill skips PtyBridge.stop()'s + * own delete). Called once, the first time THIS process ever deploys + * anything — never mid-process, since a sibling deployment from earlier + * in the SAME process run is still live and must not be swept. + */ + private fun sweepStaleDeploysOnce(baseDir: File) { + if (sweptStaleDeploysThisProcess) return + sweptStaleDeploysThisProcess = true + try { + File(baseDir, "claude-code-doc-comments-mcp").deleteRecursively() + } catch (e: Exception) { + android.util.Log.w("ClaudeCodeDocCommentsMcp", "leftover-deploy sweep failed (non-fatal)", e) + } + } + + /** What PtyBridge.start()'s combined-flags step and the caller (for the + * pending-mutation queue, the permission-auto-approve wiring, and + * PtyBridge.stop()'s own cleanup, finding #3) need. */ + data class Deployment( + val configPath: String, + /** Just `mcp__{serverId}__ReadFileComments` — see this object's own + * doc comment for why the five mutation tools are absent. */ + val allowedTools: List, + val serverId: String, + val token: String, + /** This deployment's own directory — PtyBridge.stop() deletes it + * wholesale when the session ends (finding #3). */ + val deployDir: String, + ) + + /** The --mcp-config contents. Mirrors ClaudeCodeMcp.configJson's WHY + * (linker64, never bare node — SELinux refuses a direct exec of the + * embedded node from app_data_file, and Claude Code spawns MCP servers + * itself with its own environment) plus this server's own per-session + * `env` (the ONE trusted project root, never model input — §1.5 — and + * this session's own secret token, §9.1 finding #1). */ + fun configJson(linkerPath: String, nodePath: String, serverPath: String, serverId: String, projectRoot: String, token: String): String { + val server = JSONObject() + .put("type", "stdio") + .put("command", linkerPath) + .put("args", JSONArray(listOf(nodePath, serverPath))) + .put("env", JSONObject().put(PROJECT_ROOT_ENV, projectRoot).put(TOKEN_ENV, token)) + return JSONObject().put("mcpServers", JSONObject().put(serverId, server)).toString(2) + } + + /** + * Write the server + config into `baseDir`/claude-code-doc-comments-mcp/ + * `serverId` and return what PtyBridge.start() and DocCommentsPendingQueue + * need. Returns null (never throws) if anything failed — a session + * without the doc-comments tools is a working session, one pointed at a + * missing --mcp-config file is not; the caller mirrors ClaudeCodeMcp + * .deploy's own "best-effort, log and continue" posture. + * + * No `mobileSessionId` parameter (T9c/T20 review, finding #2 fix) — this + * function's own freshly-minted `serverId` is what names the directory, + * so nothing a caller passes (or fails to pass) can affect path + * uniqueness. See this object's own doc comment for the full reasoning. + */ + fun deploy( + baseDir: File, + serverSource: String, + linkerPath: String, + nodePath: String, + projectRoot: String, + ): Deployment? { + return try { + sweepStaleDeploysOnce(baseDir) + val serverId = "${DocCommentsMcpNames.SERVER_PREFIX}-${randomHex(4)}" + // 32 hex chars, fixed-length — mirrors deployClaudeCodeDocCommentsMcp's + // own 16-random-bytes choice so a constant-time comparison on the + // applying end never has to special-case a variable-length input + // from a legitimate caller (adversarial review finding #1). + // + // WHY this token is real, tested defense against SOME threats and + // not others (T9c/T20 review, finding #1 — triage, review 2026- + // 09-27): it stops a request PLANTED before this session existed + // (nothing before spawn time could know it) and a request from a + // DIFFERENT session/process that never received it. It does NOT, + // and was never meant to, stop code running INSIDE this already- + // live session (this session's own Bash/Write tools, a skill file + // it runs, a compromised dependency it executes) from reading its + // own token and forging a request — that code already has the + // SAME filesystem access needed to edit the target .docx/.xlsx + // directly, with no token required at all, and in `default` + // permission mode that same Bash/Write access would itself + // trigger an ordinary PermissionRequest prompt. The token's job + // is closing the pre-planted-file and cross-session/cross-process + // mix-up cases, never same-uid code execution — see the review + // file's own triage section for the full reasoning. + val token = randomHex(16) + val dir = File(baseDir, "claude-code-doc-comments-mcp/$serverId") + dir.mkdirs() + restrictToOwner(dir, isDirectory = true) + val serverFile = File(dir, SERVER_FILE) + serverFile.writeText(serverSource) + val configFile = File(dir, CONFIG_FILE) + configFile.writeText(configJson(linkerPath, nodePath, serverFile.absolutePath, serverId, projectRoot, token)) + // Owner-only (finding #1, cheap hardening) — the config file is + // the one that actually holds the token; see restrictToOwner's + // own doc comment for exactly what this does and doesn't defend + // against. + restrictToOwner(configFile, isDirectory = false) + Deployment( + configPath = configFile.absolutePath, + allowedTools = listOf(DocCommentsMcpNames.readTool(serverId)), + serverId = serverId, + token = token, + deployDir = dir.absolutePath, + ) + } catch (e: Exception) { + android.util.Log.w("ClaudeCodeDocCommentsMcp", "doc-comments MCP deploy failed; session starts without comment tools", e) + null + } + } +} diff --git a/app/src/main/kotlin/com/youcoded/app/runtime/ClaudeCodeMcp.kt b/app/src/main/kotlin/com/youcoded/app/runtime/ClaudeCodeMcp.kt index 727de6bb5..e5e1de61c 100644 --- a/app/src/main/kotlin/com/youcoded/app/runtime/ClaudeCodeMcp.kt +++ b/app/src/main/kotlin/com/youcoded/app/runtime/ClaudeCodeMcp.kt @@ -30,7 +30,17 @@ object ClaudeCodeMcp { /** Asset filename, also the on-disk filename. */ const val SERVER_FILE = "send-user-link-mcp.js" - private const val CONFIG_FILE = "mcp-config.json" + // WHY public (T9c/T20): PtyBridge.start() now composes ONE combined + // --mcp-config/--allowedTools flag pair for every MCP server a session + // gets (SendUserLink + the doc-comments server) rather than one flag + // occurrence per server — Claude Code's CLI parser treats a repeated + // single-value flag as "last value wins" (verified against the installed + // CLI's own --help, mirrored from desktop's session-manager.ts), so a + // second occurrence would silently un-approve SendUserLink. That combiner + // needs this deploy's own config path, which `deploy()` below writes but + // previously had no way to hand back except embedded inside the returned + // flag string. + const val CONFIG_FILE = "mcp-config.json" /** * The --mcp-config contents. diff --git a/app/src/main/kotlin/com/youcoded/app/runtime/ManagedSession.kt b/app/src/main/kotlin/com/youcoded/app/runtime/ManagedSession.kt index cf34cf434..7f4692215 100644 --- a/app/src/main/kotlin/com/youcoded/app/runtime/ManagedSession.kt +++ b/app/src/main/kotlin/com/youcoded/app/runtime/ManagedSession.kt @@ -215,12 +215,27 @@ class ManagedSession( bridgeServer?.let { server -> when (event) { is HookEvent.PermissionRequest -> { + // doc-comments build design §5.2a (decided option + // 1, T20): a call to one of the six document- + // comment MCP tools (com.youcoded.app.doccomments. + // DocCommentsPermission, mirroring desktop's + // permission-auto-approve.ts) is auto-approved + // UNCONDITIONALLY for a plain-text/markdown/code + // target, and only in an already-frictionless + // permission mode for a Word/Excel target — this + // runs BEFORE shouldAutoApprove below because a + // doc-comments tool call is never AskUserQuestion/ + // ExitPlanMode and never matches any of that + // function's own Bash/file-path regexes. + val docCommentsApprove = com.youcoded.app.doccomments.shouldAutoApproveDocComment( + event.toolName, event.toolInput, event.permissionMode, bridge.docCommentsServerId, + ) // Auto-approve per the user's override settings. WHY one call // (2026-09-24): the decision is shouldAutoApprove in // PermissionAutoApprove.kt, which never allows ExitPlanMode or // AskUserQuestion — Claude Code ignores a hook allow for them, // so an allow only hid the card while the menu stayed live. - if (shouldAutoApprove(event.toolName, event.toolInput, permissionOverridesCache)) { + if (docCommentsApprove || shouldAutoApprove(event.toolName, event.toolInput, permissionOverridesCache)) { val decision = JSONObject().put("decision", JSONObject().put("behavior", "allow")) ptyBridge?.getEventBridge()?.respond(event.requestId, decision) diff --git a/app/src/main/kotlin/com/youcoded/app/runtime/PtyBridge.kt b/app/src/main/kotlin/com/youcoded/app/runtime/PtyBridge.kt index 1fa87c2c9..f4c0493f5 100644 --- a/app/src/main/kotlin/com/youcoded/app/runtime/PtyBridge.kt +++ b/app/src/main/kotlin/com/youcoded/app/runtime/PtyBridge.kt @@ -28,6 +28,20 @@ class PtyBridge( val socketPath: String get() = socketName val homeDir: File get() = bootstrap.homeDir + /** This session's own doc-comments MCP server id (T9c/T20) — set by + * start() once ClaudeCodeDocCommentsMcp.deploy succeeds, null otherwise + * (a failed deploy, or before start() has run). Read by + * ManagedSession.kt's PermissionRequest handling + * (DocCommentsPermission.shouldAutoApproveDocComment) — never a fixed, + * guessable value (adversarial review 2026-09-27, finding #2). */ + var docCommentsServerId: String? = null + private set + private var docCommentsToken: String? = null + private var docCommentsProjectRoot: String? = null + /** This deployment's own directory (T9c/T20 review, finding #3) — deleted + * wholesale in stop() once the session ends. */ + private var docCommentsDeployDir: String? = null + private val _screenVersion = MutableStateFlow(0) val screenVersion: StateFlow = _screenVersion @@ -113,6 +127,24 @@ class PtyBridge( eventBridge = bridge } + /** + * T20: start this session's own docx/xlsx pending-mutation queue — + * called by SessionRegistry AFTER start() so docCommentsServerId/token + * are already set. A no-op when start() hasn't run yet, or when the + * doc-comments MCP deploy failed (no token to trust anything with) — + * mirrors deployClaudeCodeDocCommentsMcp's own "best-effort" posture: + * a session without a working queue still runs, it just can't apply a + * Word/Excel comment mutation the assistant asks for. + */ + fun startDocCommentsQueue() { + val token = docCommentsToken ?: return + val root = docCommentsProjectRoot ?: return + val id = mobileSessionId ?: return + com.youcoded.app.doccomments.DocCommentsPendingQueue.start( + id, root, bootstrap.homeDir, File(bootstrap.homeDir, ".claude"), token, + ) + } + fun start() { val env = bootstrap.buildRuntimeEnv().toMutableMap() apiKey?.let { env["ANTHROPIC_API_KEY"] = it } @@ -152,16 +184,66 @@ class PtyBridge( val dangerousFlag = if (dangerousMode) " --dangerously-skip-permissions" else "" val resumeFlag = if (resumeSessionId != null) " --resume $resumeSessionId" else "" val modelFlag = if (model != null) " --model $model" else "" - // Give this session YouCoded's SendUserLink tool — Claude Code has no - // link deliverable of its own (ClaudeCodeMcp.kt / claude-code-mcp.ts). - // Deployed next to the wrapper, from the same shared asset the desktop - // embeds, and attached per session: nothing lands in ~/.claude.json. - val mcpFlags = ClaudeCodeMcp.deploy( + // Give this session YouCoded's MCP-attached tools. WHY combined into + // ONE --mcp-config/--allowedTools flag pair rather than one flag + // occurrence per server (T9c/T20 fix, mirroring desktop's own + // session-manager.ts comment at its identical combine step): Claude + // Code's CLI treats a repeated single-value flag as "last value + // wins" (verified against the installed CLI's own --help — both + // flags are documented as accepting multiple space-separated + // values in ONE occurrence) — a second, separate + // `--mcp-config X --allowedTools Y` for the doc-comments server + // would otherwise silently un-approve SendUserLink. + val mcpConfigPaths = mutableListOf() + val allowedToolNames = mutableListOf() + // SendUserLink — Claude Code has no link deliverable of its own + // (ClaudeCodeMcp.kt / claude-code-mcp.ts). Deployed next to the + // wrapper, from the same shared asset the desktop embeds, and + // attached per session: nothing lands in ~/.claude.json. + val linkFlags = ClaudeCodeMcp.deploy( mobileDir, context.assets.open(ClaudeCodeMcp.SERVER_FILE).bufferedReader().readText(), "/system/bin/linker64", nodePath.absolutePath, ) + if (linkFlags.isNotEmpty()) { + mcpConfigPaths.add(File(mobileDir, ClaudeCodeMcp.CONFIG_FILE).absolutePath) + allowedToolNames.add(ClaudeCodeMcp.TOOL_NAME) + } + // The six document-comment tools (doc-comments build design §5, §9, + // T9c/T20) — ReadFileComments alone is pre-approved (a read never + // mutates anything); the five mutation tools are deliberately NOT + // allow-listed here (see ClaudeCodeDocCommentsMcp.kt's own header and + // DocCommentsPermission.kt for the real per-call mechanism). + // `cwd` becomes the ONE trusted project root every tool call on this + // surface is contained to — never a value the model can influence. + // No mobileSessionId argument (T9c/T20 review, finding #2 fix) — + // ClaudeCodeDocCommentsMcp.deploy names its own directory from the + // serverId it mints internally, never from a caller-supplied value, + // so there is no placeholder-fallback path left to reintroduce a + // fixed, collision-prone directory. + val docComments = try { + ClaudeCodeDocCommentsMcp.deploy( + mobileDir, + context.assets.open(ClaudeCodeDocCommentsMcp.SERVER_FILE).bufferedReader().readText(), + "/system/bin/linker64", + nodePath.absolutePath, + cwd.absolutePath, + ) + } catch (e: Exception) { + android.util.Log.w("PtyBridge", "doc-comments MCP deploy failed — this session starts without comment tools", e) + null + } + if (docComments != null) { + mcpConfigPaths.add(docComments.configPath) + allowedToolNames.addAll(docComments.allowedTools) + docCommentsServerId = docComments.serverId + docCommentsToken = docComments.token + docCommentsProjectRoot = cwd.absolutePath + docCommentsDeployDir = docComments.deployDir + } + val mcpFlags = if (mcpConfigPaths.isEmpty()) "" + else " --mcp-config ${mcpConfigPaths.joinToString(" ")} --allowedTools ${allowedToolNames.joinToString(" ")}" val launchCmd = "exec /system/bin/linker64 ${nodePath.absolutePath} ${wrapperPath.absolutePath} ${claudePath.absolutePath}$dangerousFlag$resumeFlag$modelFlag$mcpFlags" File(bootstrap.homeDir, "tmp").mkdirs() @@ -317,6 +399,26 @@ class PtyBridge( fun stop() { eventBridge?.stop() + // T20: drop this session's ref on its project's pending-mutation + // queue — the queue itself keeps running for as long as any OTHER + // session shares the same project root (mirrors desktop's own + // refcounted stopPendingMutationQueue). + val root = docCommentsProjectRoot + val id = mobileSessionId + if (root != null && id != null) { + com.youcoded.app.doccomments.DocCommentsPendingQueue.stop(id, root) + } + // T9c/T20 review, finding #3: delete this session's own doc-comments + // deploy directory (config + token) now that it's genuinely done — + // a process kill/crash that skips this still gets swept on the next + // deploy in a fresh process (ClaudeCodeDocCommentsMcp.sweepStaleDeploysOnce). + docCommentsDeployDir?.let { dir -> + try { + File(dir).deleteRecursively() + } catch (e: Exception) { + android.util.Log.w("PtyBridge", "doc-comments deploy dir cleanup failed (non-fatal)", e) + } + } session?.finishIfRunning() session = null _rawBuffer.setLength(0) diff --git a/app/src/main/kotlin/com/youcoded/app/runtime/SessionRegistry.kt b/app/src/main/kotlin/com/youcoded/app/runtime/SessionRegistry.kt index ef4c10c39..0fb8cf180 100644 --- a/app/src/main/kotlin/com/youcoded/app/runtime/SessionRegistry.kt +++ b/app/src/main/kotlin/com/youcoded/app/runtime/SessionRegistry.kt @@ -69,6 +69,10 @@ class SessionRegistry { // Start EventBridge BEFORE Claude Code — hooks fire immediately on launch bridge.startEventBridge(scope) bridge.start() + // T20: start this session's docx/xlsx pending-mutation queue AFTER + // start() — that's when docCommentsServerId/token are known (a + // failed doc-comments MCP deploy makes this a no-op). + bridge.startDocCommentsQueue() session.startTitleObserver() // Wire up the current-session check for blue dot logic diff --git a/app/src/main/kotlin/com/youcoded/app/runtime/SessionService.kt b/app/src/main/kotlin/com/youcoded/app/runtime/SessionService.kt index aa34b255e..39b331466 100644 --- a/app/src/main/kotlin/com/youcoded/app/runtime/SessionService.kt +++ b/app/src/main/kotlin/com/youcoded/app/runtime/SessionService.kt @@ -929,6 +929,17 @@ class SessionService : Service() { super.onDestroy() } + // ── Document comments (docComments:*) shared plumbing — T4 of the + // doc-comments build (docs/active/specs/2026-09-26-doc-comments-build- + // design.md §1.5, §1.6). The actual dispatch logic (the "is this + // projectRoot known" gate plus every list/add/reply/resolve/reopen/move + // branch) lives in `com.youcoded.app.doccomments.handleDocCommentsMessage` + // (DocCommentsBridge.kt) — extracted out of this Service's own `when` + // block (F6, T4 implementation review) so it can be driven directly by a + // JVM unit test with no running Service. Only `homeDir` needs a live + // Service to compute (`bootstrap?.homeDir`). + private fun docCommentsHomeDir(): File = bootstrap?.homeDir ?: filesDir + private suspend fun handleBridgeMessage( ws: org.java_websocket.WebSocket, msg: MessageRouter.ParsedMessage @@ -4178,6 +4189,71 @@ class SessionService : Service() { org.json.JSONObject().put("ok", false).put("error", "not-implemented-on-mobile")) } } + // ── Document comments (docComments:*) — T4 of the doc-comments + // build (docs/active/specs/2026-09-26-doc-comments-build-design.md + // §1.6, §3.2a, §4.3a). Unlike the git block above, list/add/reply/ + // resolve/reopen/move are REAL for every target type — reopen-1 + // ("full phone support", Destin) supersedes the desktop-only scope + // an earlier revision of the design gave this feature. A + // plain-text/markdown `PersistedComment` target is a real + // `java.io.File` read/write (DocCommentsStore.kt, same precedent + // as artifacts:get/save); a `.docx`/`.xlsx` target's READ dispatches + // to T16/T18's own Kotlin readers (DocCommentsDispatch.kt) — its + // WRITE refuses honestly with `not-yet-supported` until T17/T19 + // build the Kotlin write halves (see DocCommentsDispatch.kt's own + // header for why that split is correct, not a gap in THIS task). + // watch/unwatch stay `not-implemented-on-mobile` for every file + // type — a general "no FileObserver-based push" gap (§1.6), + // unrelated to reopen-1's promise about read/add/reply/resolve. + // + // F6 (T4 implementation review): the actual per-type dispatch + // (field validation, the projectRoot gate, every store call, the + // exact response envelope) lives in + // `com.youcoded.app.doccomments.handleDocCommentsMessage` + // (DocCommentsBridge.kt) — extracted so it's directly unit- + // testable (DocCommentsBridgeTest.kt) with no running Service. + // This branch is now just plumbing: compute the two inputs that + // DO need a live Service (`homeDir`, this session's own live cwds) + // and hand the message off. + "docComments:list", "docComments:add", "docComments:reply", "docComments:resolve", + "docComments:reopen", "docComments:move", + // Edit/delete build (2026-09-28, design doc §"Edit and delete") — + // appended after "docComments:move" and before the watch/unwatch + // pair, matching desktop's own ipc-channels.ts ordering (EDIT, + // EDIT_REPLY, DELETE, DELETE_REPLY, then WATCH/UNWATCH). Existing + // labels are never reordered. + "docComments:edit", "docComments:edit-reply", "docComments:delete", "docComments:delete-reply", + "docComments:watch", "docComments:unwatch" -> { + val homeDir = docCommentsHomeDir() + val sessionRoots = sessionRegistry.sessions.value.values.map { it.cwd.absolutePath } + // F2 (T17 implementation review, major/crash risk): a + // format-agnostic LAST-RESORT backstop around the whole + // dispatch call. `addNativeDocxComment`/etc. (DocCommentsDispatch.kt) + // already catch every exception their own write path can + // throw, but this `when` branch runs inside + // `serviceScope.launch { handleBridgeMessage(ws, msg) }` + // (onCreate() above) with NO CoroutineExceptionHandler + // installed on `serviceScope` — an exception this dispatch + // layer somehow still didn't catch (a bug in a FUTURE + // change here, or a wholly different docComments:* path, + // e.g. the sidecar store's own JSON handling) would + // otherwise kill the coroutine silently: no response is ever + // sent, so the WebView's caller hangs forever, and on + // Android an uncaught exception with no handler installed on + // its scope is a PROCESS CRASH, not just a dropped message. + // Every docComments:* message gets an answer either way. + val response = try { + com.youcoded.app.doccomments.handleDocCommentsMessage(msg.type, msg.payload, homeDir, sessionRoots) + // Unreachable in practice — every label in this branch's + // own match arm above is also one handleDocCommentsMessage + // owns; kept as an honest fallback rather than `!!`. + ?: org.json.JSONObject().put("ok", false).put("error", "not-implemented-on-mobile") + } catch (_: Exception) { + org.json.JSONObject().put("ok", false).put("error", "write-failed") + } + msg.id?.let { bridgeServer.respond(ws, msg.type, it, response) } + } + // Project View hub (conversations, repo, context) is desktop-only in v1 // (see docs/superpowers/specs/2026-06-14-project-view-redesign-design.md). // Reply not-implemented so the shared React UI can degrade to an diff --git a/app/src/test/kotlin/com/youcoded/app/artifacts/CasWriteTest.kt b/app/src/test/kotlin/com/youcoded/app/artifacts/CasWriteTest.kt new file mode 100644 index 000000000..7da4416b7 --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/artifacts/CasWriteTest.kt @@ -0,0 +1,140 @@ +// Pins F1 (T4 doc-comments implementation review, blocker): `mutateFileUnder +// Lock` is the Kotlin port of desktop's `cas-write.ts` primitive of the SAME +// name — the mkdir-based lock, 30s stale-lock break, and atomic +// tmp-then-rename write that lets THIS app, a dev-instance-plus-built-app +// pair on desktop, AND the Claude Code MCP script's own dependency-free +// reimplementation (doc-comments design §9.1/§9.2) all exclude each other +// over the SAME file, not just coroutines inside one JVM. +// +// These tests spawn a REAL second OS process (a plain `sh` child) rather +// than a second coroutine or thread: a second coroutine only proves the +// in-process Mutex some callers layer on top works — it says nothing about +// whether the ON-DISK lock protocol itself (a directory a foreign, +// dependency-free process can mkdir/rmdir with no shared runtime at all) +// actually excludes something outside this JVM, which is the entire point +// of this primitive existing. +package com.youcoded.app.artifacts + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private fun tempTarget(): File = Files.createTempFile("ycd-cas-write-", ".txt").toFile().apply { + delete() // mutateFileUnderLock must handle "doesn't exist yet" + deleteOnExit() +} + +class CasWriteTest { + + @Test + fun `round-trips a read-modify-write with no lock contention, including file creation`() { + val target = tempTarget() + var sawOnDisk: String? = "not called" + val acquired = mutateFileUnderLock(target.toPath()) { onDisk -> + sawOnDisk = onDisk + "1" + } + assertTrue(acquired) + assertEquals(null, sawOnDisk) + assertEquals("1", target.readText()) + + val acquired2 = mutateFileUnderLock(target.toPath()) { onDisk -> ((onDisk?.toIntOrNull() ?: 0) + 1).toString() } + assertTrue(acquired2) + assertEquals("2", target.readText()) + } + + @Test + fun `returning null from mutate skips the write entirely`() { + val target = tempTarget() + val acquired = mutateFileUnderLock(target.toPath()) { null } + assertTrue(acquired) + assertTrue(!target.exists(), "a null mutate result must never create the file") + } + + @Test + fun `a real second OS process holding the lock directory blocks the writer until it releases`() { + val target = tempTarget() + val lockDir = File(target.parentFile, target.name + ".lock") + val releasedMarker = File(target.parentFile, target.name + ".released-marker") + releasedMarker.deleteOnExit() + // A plain `sh` child — no JVM, no node_modules, exactly the + // "dependency-free" shape the MCP script itself is constrained to + // (doc-comments design §9.1 point 2) — mkdir's the SAME lock + // directory this primitive uses, holds it briefly, drops a marker + // right before releasing, then releases. + val proc = ProcessBuilder( + "sh", "-c", + "mkdir '${lockDir.path}' && sleep 0.4 && touch '${releasedMarker.path}' && rmdir '${lockDir.path}'", + ).start() + // Wait for the child to actually acquire the lock before racing it — + // otherwise this test could spuriously pass by winning a startup race. + val waitStart = System.currentTimeMillis() + while (!lockDir.isDirectory) { + assertTrue(System.currentTimeMillis() - waitStart < 2000, "child process never created the lock dir") + Thread.sleep(5) + } + + val acquired = mutateFileUnderLock(target.toPath()) { "written-after-release" } + proc.waitFor() + + assertTrue(acquired) + assertEquals("written-after-release", target.readText()) + // The real assertion: this call did not return until the foreign + // process had ALREADY dropped the marker on its way to releasing the + // lock — a signal, never a fixed sleep or a wall-clock guess. + assertTrue(releasedMarker.exists(), "mutateFileUnderLock returned before the foreign process released its lock") + } + + @Test + fun `a real two-process race increments a shared counter with no lost update`() { + val target = tempTarget() + target.writeText("0") + val lockDir = File(target.parentFile, target.name + ".lock") + + // A foreign process implementing the SAME mkdir-lock-plus-atomic- + // rename algorithm from scratch, in POSIX shell — standing in for the + // MCP script's own from-scratch reimplementation (design §9.1 point + // 2: "it cannot import cas-write.ts... it needs a small, + // dependency-free reimplementation of the SAME algorithm"). Retries + // mkdir in a tight loop (POSIX mkdir is atomic — the same property + // Files.createDirectory relies on here), reads-increments-writes the + // counter via a temp file + atomic `mv`, then rmdir's the lock. + val shellIncrements = 15 + val script = """ + n=0 + while [ ${'$'}n -lt $shellIncrements ]; do + if mkdir '${lockDir.path}' 2>/dev/null; then + v=${'$'}(cat '${target.path}') + v=${'$'}((v + 1)) + echo -n ${'$'}v > '${target.path}.shtmp' + mv '${target.path}.shtmp' '${target.path}' + rmdir '${lockDir.path}' + n=${'$'}((n + 1)) + else + sleep 0.01 + fi + done + """.trimIndent() + val proc = ProcessBuilder("sh", "-c", script).start() + + val kotlinIncrements = 15 + runBlocking { + val jobs = (0 until kotlinIncrements).map { + async(Dispatchers.IO) { + val ok = mutateFileUnderLock(target.toPath()) { onDisk -> ((onDisk?.trim()?.toIntOrNull() ?: 0) + 1).toString() } + assertTrue(ok, "the Kotlin writer failed to acquire the lock within the timeout") + } + } + jobs.awaitAll() + } + assertEquals(0, proc.waitFor()) + + assertEquals(shellIncrements + kotlinIncrements, target.readText().trim().toInt()) + } +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsBridgeTest.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsBridgeTest.kt new file mode 100644 index 000000000..a7986a7ae --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsBridgeTest.kt @@ -0,0 +1,536 @@ +// F6 (T4 doc-comments implementation review): drives handleDocCommentsMessage +// — the pure function DocCommentsBridge.kt extracted out of SessionService. +// handleBridgeMessage's docComments:* `when` block (see that file's own +// header for why) — and asserts the REAL response JSON shape for every +// message type: exact key sets, `ok`/`error` codes, and value types, never a +// regex over a serialized string. Complements DocCommentsStoreTest.kt (which +// drives the store functions directly) by pinning the IPC-facing envelope. +package com.youcoded.app.doccomments + +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private fun tempRoot(): File = Files.createTempDirectory("ycd-doccomments-bridge-").toFile().apply { deleteOnExit() } + +private val TEXT_SELECTOR_JSON = JSONObject() + .put("kind", "text") + .put( + "selector", + JSONObject().put("type", "TextQuoteSelector").put("exact", "hello").put("prefix", "").put("suffix", " world").put("occurrence", 0), + ) + +private fun cellSelectorJson(cell: String): JSONObject = + JSONObject().put("kind", "cell").put("selector", JSONObject().put("type", "CellSelector").put("cell", cell)) + +/** Copies a real test-resource fixture (`/doc-comments/`) to + * `/` — used by F3 (android-xlsx-review)'s bridge-level + * end-to-end test so `docComments:*` messages can be driven against a REAL + * `.xlsx` file on disk, not a nonexistent-target routing check. */ +private fun copyFixtureInto(root: File, name: String, relativePath: String): File { + val dest = File(root, relativePath) + dest.parentFile?.mkdirs() + val resourceStream = object {}.javaClass.getResourceAsStream("/doc-comments/$name") + ?: error("missing test resource doc-comments/$name") + resourceStream.use { input -> dest.outputStream().use { output -> input.copyTo(output) } } + return dest +} + +class DocCommentsBridgeTest { + + @Test + fun `an unrecognized message type is not owned by this handler`() = runTest { + val response = handleDocCommentsMessage("something:else", JSONObject(), tempRoot(), emptyList()) + assertNull(response) + } + + @Test + fun `list with no path refuses missing-field for path`() = runTest { + val response = handleDocCommentsMessage("docComments:list", JSONObject(), tempRoot(), emptyList())!! + assertEquals(false, response.getBoolean("ok")) + assertEquals("missing-field", response.getString("error")) + assertEquals("path", response.getString("field")) + } + + @Test + fun `list against a file with no sidecar returns ok true and an empty comments array, exactly those two keys`() = runTest { + val root = tempRoot() + val payload = JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path) + val response = handleDocCommentsMessage("docComments:list", payload, root, listOf(root.path))!! + assertEquals(true, response.getBoolean("ok")) + assertEquals(0, response.getJSONArray("comments").length()) + assertEquals(setOf("ok", "comments"), response.keys().asSequence().toSet()) + } + + @Test + fun `an unrecognized projectRoot refuses unknown-project-root before ever touching the store`() = runTest { + val root = tempRoot() + val unknown = tempRoot() + val payload = JSONObject().put("path", "docs/plan.md").put("projectRoot", unknown.path) + val response = handleDocCommentsMessage("docComments:list", payload, root, listOf(root.path))!! + assertEquals(JSONObject().put("ok", false).put("error", "unknown-project-root").toString(), response.toString()) + } + + @Test + fun `add returns ok true and a real generated comment id, and list then reflects it`() = runTest { + val root = tempRoot() + val addPayload = JSONObject() + .put("path", "docs/plan.md") + .put("projectRoot", root.path) + .put("text", "Can we cut this?") + .put("selector", TEXT_SELECTOR_JSON) + val addResponse = handleDocCommentsMessage("docComments:add", addPayload, root, listOf(root.path))!! + assertEquals(true, addResponse.getBoolean("ok")) + val id = addResponse.getString("id") + assertTrue(id.startsWith("c-"), "expected a c- id, got $id") + assertEquals(setOf("ok", "id"), addResponse.keys().asSequence().toSet()) + + val listResponse = handleDocCommentsMessage( + "docComments:list", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path), + root, listOf(root.path), + )!! + assertEquals(1, listResponse.getJSONArray("comments").length()) + assertEquals(id, listResponse.getJSONArray("comments").getJSONObject(0).getString("id")) + } + + // F4 (T5 implementation review): the shared React UI's renderer store + // mints the comment id and sends it on the payload — forwarded straight + // through to the store here, mirroring desktop's own ipc-handlers.ts. + @Test + fun `forwards a caller-supplied id straight through to the store`() = runTest { + val root = tempRoot() + val callerId = "c-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + val addPayload = JSONObject() + .put("path", "docs/plan.md") + .put("projectRoot", root.path) + .put("text", "hello") + .put("selector", TEXT_SELECTOR_JSON) + .put("id", callerId) + val addResponse = handleDocCommentsMessage("docComments:add", addPayload, root, listOf(root.path))!! + assertEquals(true, addResponse.getBoolean("ok")) + assertEquals(callerId, addResponse.getString("id")) + + val listResponse = handleDocCommentsMessage( + "docComments:list", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path), + root, listOf(root.path), + )!! + assertEquals(callerId, listResponse.getJSONArray("comments").getJSONObject(0).getString("id")) + } + + @Test + fun `add with no selector refuses missing-field for selector`() = runTest { + val root = tempRoot() + val payload = JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("text", "x") + val response = handleDocCommentsMessage("docComments:add", payload, root, listOf(root.path))!! + assertEquals("missing-field", response.getString("error")) + assertEquals("selector", response.getString("field")) + } + + // T17: docx add is now real, dispatched to DocxComments.kt's write + // pipeline instead of refusing — a target that doesn't actually exist on + // disk gets the write pipeline's own honest `read-failed` refusal (it + // never fabricates a fresh .docx from nothing), never the OLD + // `not-yet-supported` answer or a silent sidecar-store write (§1.1: Word + // comments never get a PersistedComment sidecar row). + @Test + fun `add against a docx target dispatches to the real T17 write pipeline, never the sidecar store`() = runTest { + val root = tempRoot() + val payload = JSONObject() + .put("path", "docs/plan.docx") + .put("projectRoot", root.path) + .put("text", "x") + .put("selector", TEXT_SELECTOR_JSON) + val response = handleDocCommentsMessage("docComments:add", payload, root, listOf(root.path))!! + assertEquals(false, response.getBoolean("ok")) + assertEquals("read-failed", response.getString("error")) + + // Confirms this never silently fell through to the sidecar store: a + // sidecar-backed add would have created `.youcoded/comments/docs/ + // plan.docx.json` — Word/Excel comments never get one (§1.1). + assertTrue(!File(root, ".youcoded/comments/docs/plan.docx.json").exists()) + } + + // T19: xlsx add is now real too, dispatched to XlsxComments.kt's write + // pipeline instead of refusing — mirrors the docx test immediately above, + // field-for-field. A `.xlsx` add uses a CellSelector, never a + // TextQuoteSelector. + @Test + fun `add against an xlsx target dispatches to the real T19 write pipeline, never the sidecar store`() = runTest { + val root = tempRoot() + val cellSelectorJson = JSONObject().put("kind", "cell").put("selector", JSONObject().put("type", "CellSelector").put("cell", "A1")) + val payload = JSONObject() + .put("path", "reports/q3.xlsx") + .put("projectRoot", root.path) + .put("text", "x") + .put("selector", cellSelectorJson) + val response = handleDocCommentsMessage("docComments:add", payload, root, listOf(root.path))!! + assertEquals(false, response.getBoolean("ok")) + assertEquals("read-failed", response.getString("error")) + + // Confirms this never silently fell through to the sidecar store: a + // sidecar-backed add would have created `.youcoded/comments/reports/ + // q3.xlsx.json` — Word/Excel comments never get one (§1.1). + assertTrue(!File(root, ".youcoded/comments/reports/q3.xlsx.json").exists()) + } + + // Review finding #5 (docs/active/reviews/2026-09-27-doc-comments-t9ab- + // review.md): `nativeFormatFor(filePath)` used to decide format from the + // caller's RAW path string, so a `.txt`-named symlink pointing at a real + // `.docx` was dispatched as plain text — its comment would have landed in + // the inert JSON sidecar instead of the real document. Fixed by deciding + // from `resolveNativeFormat` (the resolved, realpath'd target). A + // sidecar-backed add always succeeds regardless of the target's actual + // bytes; dispatching to the REAL docx write pipeline instead means this + // fails on the file's genuinely invalid (not a real zip/docx) content — + // `ok: false` plus no sidecar file is what proves it took the native + // path, not the sidecar one. + @Test + fun `a txt symlink pointing at a real docx dispatches to the native write pipeline, not the sidecar store`() = runTest { + val root = tempRoot() + val docsDir = File(root, "docs").apply { mkdirs() } + val realDocx = File(docsDir, "report.docx") + realDocx.writeText("not a real docx, just bytes for this test") + val link = File(docsDir, "notes.txt") + try { + Files.createSymbolicLink(link.toPath(), realDocx.toPath()) + } catch (_: Exception) { + return@runTest // no symlink rights on this platform — skip, same precedent as DocCommentsStoreTest.kt + } + val payload = JSONObject() + .put("path", "docs/notes.txt") + .put("projectRoot", root.path) + .put("text", "x") + .put("selector", TEXT_SELECTOR_JSON) + val response = handleDocCommentsMessage("docComments:add", payload, root, listOf(root.path))!! + assertEquals(false, response.getBoolean("ok")) + assertEquals("invalid-docx", response.getString("error")) + assertTrue(!File(root, ".youcoded/comments/docs/notes.txt.json").exists()) + } + + @Test + fun `reply carries the persisted CommentReply, resolve and reopen answer with exactly ok true, and all three persist`() = runTest { + val root = tempRoot() + val id = handleDocCommentsMessage( + "docComments:add", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("text", "x").put("selector", TEXT_SELECTOR_JSON), + root, listOf(root.path), + )!!.getString("id") + + // T5 review parity (design §1.6, F2): `reply`'s response is enriched + // to carry the real persisted `CommentReply` — the SAME shape + // desktop's own `docComments:reply` response now returns. + val replyResponse = handleDocCommentsMessage( + "docComments:reply", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id).put("text", "reply text"), + root, listOf(root.path), + )!! + assertEquals(setOf("ok", "reply"), replyResponse.keys().asSequence().toSet()) + assertEquals(true, replyResponse.getBoolean("ok")) + val replyJson = replyResponse.getJSONObject("reply") + assertEquals("reply text", replyJson.getString("text")) + assertEquals("user", replyJson.getString("author")) + assertEquals("$id-r1", replyJson.getString("id")) + + val resolveResponse = handleDocCommentsMessage( + "docComments:resolve", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id), + root, listOf(root.path), + )!! + assertEquals(setOf("ok"), resolveResponse.keys().asSequence().toSet()) + assertEquals(true, resolveResponse.getBoolean("ok")) + + val reopenResponse = handleDocCommentsMessage( + "docComments:reopen", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id), + root, listOf(root.path), + )!! + assertEquals(setOf("ok"), reopenResponse.keys().asSequence().toSet()) + assertEquals(true, reopenResponse.getBoolean("ok")) + + val listResponse = handleDocCommentsMessage( + "docComments:list", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path), + root, listOf(root.path), + )!! + val comment = listResponse.getJSONArray("comments").getJSONObject(0) + assertEquals(false, comment.getBoolean("resolved")) + assertEquals(1, comment.getJSONArray("replies").length()) + assertEquals(2, comment.getJSONArray("history").length()) + } + + @Test + fun `reply against an unknown comment id refuses comment-not-found`() = runTest { + val root = tempRoot() + val response = handleDocCommentsMessage( + "docComments:reply", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", "c-nope").put("text", "x"), + root, listOf(root.path), + )!! + assertEquals("comment-not-found", response.getString("error")) + } + + // F3 (android-xlsx-review, Low): xlsx reply/resolve/reopen/move were + // exercised at the module level (XlsxCommentsTest.kt) but never at the + // bridge/JSON level — this drives add->reply->resolve->reopen->move + // through `handleDocCommentsMessage` against a REAL `.xlsx` fixture, + // asserting the exact JSON envelope at every step, mirroring the + // plain-sidecar round trip test above (line ~230) field-for-field. + @Test + fun `xlsx add-reply-resolve-reopen-move round trips through the real T19 write pipeline end to end, matching desktop's own response shapes`() = runTest { + val root = tempRoot() + copyFixtureInto(root, "docling-xlsx-comments.xlsx", "reports/docling.xlsx") + + val addResponse = handleDocCommentsMessage( + "docComments:add", + JSONObject().put("path", "reports/docling.xlsx").put("projectRoot", root.path).put("text", "bridge e2e").put("selector", cellSelectorJson("C1")), + root, listOf(root.path), + )!! + assertEquals(setOf("ok", "id"), addResponse.keys().asSequence().toSet()) + assertEquals(true, addResponse.getBoolean("ok")) + val id = addResponse.getString("id") + assertTrue(id.startsWith("xt-"), "expected an xt-<...> xlsx thread id, got $id") + + val replyResponse = handleDocCommentsMessage( + "docComments:reply", + JSONObject().put("path", "reports/docling.xlsx").put("projectRoot", root.path).put("id", id).put("text", "a reply"), + root, listOf(root.path), + )!! + assertEquals(setOf("ok", "reply"), replyResponse.keys().asSequence().toSet()) + assertEquals(true, replyResponse.getBoolean("ok")) + val replyJson = replyResponse.getJSONObject("reply") + assertEquals("a reply", replyJson.getString("text")) + assertEquals("$id-r1", replyJson.getString("id")) + + val resolveResponse = handleDocCommentsMessage( + "docComments:resolve", + JSONObject().put("path", "reports/docling.xlsx").put("projectRoot", root.path).put("id", id), + root, listOf(root.path), + )!! + assertEquals(setOf("ok"), resolveResponse.keys().asSequence().toSet()) + assertEquals(true, resolveResponse.getBoolean("ok")) + + val reopenResponse = handleDocCommentsMessage( + "docComments:reopen", + JSONObject().put("path", "reports/docling.xlsx").put("projectRoot", root.path).put("id", id), + root, listOf(root.path), + )!! + assertEquals(setOf("ok"), reopenResponse.keys().asSequence().toSet()) + assertEquals(true, reopenResponse.getBoolean("ok")) + + val moveResponse = handleDocCommentsMessage( + "docComments:move", + JSONObject().put("path", "reports/docling.xlsx").put("projectRoot", root.path).put("id", id).put("newSelector", cellSelectorJson("D1")), + root, listOf(root.path), + )!! + // Coordinator review fix: desktop's own `docComments:move` response + // for an xlsx target carries the moved thread's FRESH id + // (`{ok:true, id}`) — unlike docx's bare `{ok:true}` — confirmed + // directly against `doc-comments-dispatch.ts`'s own + // `moveNativeXlsxComment` return type. This is the one assertion in + // this test that would have caught the bridge silently discarding it. + assertEquals(setOf("ok", "id"), moveResponse.keys().asSequence().toSet()) + assertEquals(true, moveResponse.getBoolean("ok")) + val movedId = moveResponse.getString("id") + assertTrue(movedId.contains("-D1-"), "the fresh id must embed the NEW cell, got $movedId") + + val listResponse = handleDocCommentsMessage( + "docComments:list", + JSONObject().put("path", "reports/docling.xlsx").put("projectRoot", root.path), + root, listOf(root.path), + )!! + assertEquals(true, listResponse.getBoolean("ok")) + val comments = listResponse.getJSONArray("comments") + var found: JSONObject? = null + for (i in 0 until comments.length()) { + val c = comments.getJSONObject(i) + if (c.getString("id") == movedId) found = c + } + assertTrue(found != null, "expected the moved comment at its fresh id in the final list") + assertEquals(false, found!!.getBoolean("resolved")) + assertEquals(1, found.getJSONArray("replies").length()) + assertEquals("D1", found.getJSONObject("selector").getJSONObject("selector").getString("cell")) + } + + @Test + fun `move requires newSelector and refuses missing-field when absent`() = runTest { + val root = tempRoot() + val id = handleDocCommentsMessage( + "docComments:add", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("text", "x").put("selector", TEXT_SELECTOR_JSON), + root, listOf(root.path), + )!!.getString("id") + val response = handleDocCommentsMessage( + "docComments:move", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id), + root, listOf(root.path), + )!! + assertEquals("missing-field", response.getString("error")) + assertEquals("newSelector", response.getString("field")) + } + + // ── Edit/delete build (2026-09-28, design doc §"Edit and delete") ─────── + + @Test + fun `edit edit-reply delete-reply and delete round trip through the sidecar, matching desktop's own response shapes`() = runTest { + val root = tempRoot() + val id = handleDocCommentsMessage( + "docComments:add", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("text", "original").put("selector", TEXT_SELECTOR_JSON), + root, listOf(root.path), + )!!.getString("id") + val replyId = handleDocCommentsMessage( + "docComments:reply", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id).put("text", "a reply"), + root, listOf(root.path), + )!!.getJSONObject("reply").getString("id") + + val editResponse = handleDocCommentsMessage( + "docComments:edit", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id).put("text", "edited text"), + root, listOf(root.path), + )!! + assertEquals(setOf("ok"), editResponse.keys().asSequence().toSet()) + assertEquals(true, editResponse.getBoolean("ok")) + + val editReplyResponse = handleDocCommentsMessage( + "docComments:edit-reply", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id).put("replyId", replyId).put("text", "edited reply"), + root, listOf(root.path), + )!! + assertEquals(setOf("ok", "reply"), editReplyResponse.keys().asSequence().toSet()) + assertEquals(true, editReplyResponse.getBoolean("ok")) + assertEquals("edited reply", editReplyResponse.getJSONObject("reply").getString("text")) + assertEquals(replyId, editReplyResponse.getJSONObject("reply").getString("id")) + + val listAfterEdits = handleDocCommentsMessage( + "docComments:list", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path), + root, listOf(root.path), + )!! + val comment = listAfterEdits.getJSONArray("comments").getJSONObject(0) + assertEquals("edited text", comment.getString("text")) + assertEquals("edited reply", comment.getJSONArray("replies").getJSONObject(0).getString("text")) + assertEquals(0, comment.getJSONArray("history").length()) // no "edited" marker is ever stored + + val deleteReplyResponse = handleDocCommentsMessage( + "docComments:delete-reply", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id).put("replyId", replyId), + root, listOf(root.path), + )!! + assertEquals(setOf("ok"), deleteReplyResponse.keys().asSequence().toSet()) + assertEquals(true, deleteReplyResponse.getBoolean("ok")) + + val listAfterDeleteReply = handleDocCommentsMessage( + "docComments:list", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path), + root, listOf(root.path), + )!! + assertEquals(0, listAfterDeleteReply.getJSONArray("comments").getJSONObject(0).getJSONArray("replies").length()) + + val deleteResponse = handleDocCommentsMessage( + "docComments:delete", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id), + root, listOf(root.path), + )!! + assertEquals(setOf("ok"), deleteResponse.keys().asSequence().toSet()) + assertEquals(true, deleteResponse.getBoolean("ok")) + + val listAfterDelete = handleDocCommentsMessage( + "docComments:list", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path), + root, listOf(root.path), + )!! + assertEquals(0, listAfterDelete.getJSONArray("comments").length()) + } + + @Test + fun `edit against an unknown comment id refuses comment-not-found`() = runTest { + val root = tempRoot() + val response = handleDocCommentsMessage( + "docComments:edit", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", "c-nope").put("text", "x"), + root, listOf(root.path), + )!! + assertEquals("comment-not-found", response.getString("error")) + } + + @Test + fun `edit edit-reply delete and delete-reply refuse missing-field for their own required fields`() = runTest { + val root = tempRoot() + val id = handleDocCommentsMessage( + "docComments:add", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("text", "x").put("selector", TEXT_SELECTOR_JSON), + root, listOf(root.path), + )!!.getString("id") + + val editNoText = handleDocCommentsMessage( + "docComments:edit", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id), + root, listOf(root.path), + )!! + assertEquals("missing-field", editNoText.getString("error")) + assertEquals("text", editNoText.getString("field")) + + val editReplyNoReplyId = handleDocCommentsMessage( + "docComments:edit-reply", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id).put("text", "x"), + root, listOf(root.path), + )!! + assertEquals("missing-field", editReplyNoReplyId.getString("error")) + assertEquals("replyId", editReplyNoReplyId.getString("field")) + + val deleteNoId = handleDocCommentsMessage( + "docComments:delete", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path), + root, listOf(root.path), + )!! + assertEquals("missing-field", deleteNoId.getString("error")) + assertEquals("id", deleteNoId.getString("field")) + + val deleteReplyNoReplyId = handleDocCommentsMessage( + "docComments:delete-reply", + JSONObject().put("path", "docs/plan.md").put("projectRoot", root.path).put("id", id), + root, listOf(root.path), + )!! + assertEquals("missing-field", deleteReplyNoReplyId.getString("error")) + assertEquals("replyId", deleteReplyNoReplyId.getString("field")) + } + + @Test + fun `edit against a docx target dispatches to the real native write pipeline, never the sidecar store`() = runTest { + val root = tempRoot() + val docsDir = File(root, "docs").apply { mkdirs() } + File(docsDir, "report.docx").writeText("not a real docx, just bytes for this test") + val response = handleDocCommentsMessage( + "docComments:edit", + JSONObject().put("path", "docs/report.docx").put("projectRoot", root.path).put("id", "w-1").put("text", "x"), + root, listOf(root.path), + )!! + assertEquals(false, response.getBoolean("ok")) + assertEquals("invalid-docx", response.getString("error")) + assertTrue(!File(root, ".youcoded/comments/docs/report.docx.json").exists()) + } + + @Test + fun `watch and unwatch both answer not-implemented-on-mobile`() = runTest { + for (type in listOf("docComments:watch", "docComments:unwatch")) { + val response = handleDocCommentsMessage(type, JSONObject(), tempRoot(), emptyList())!! + assertEquals(JSONObject().put("ok", false).put("error", "not-implemented-on-mobile").toString(), response.toString()) + } + } + + @Test + fun `a live session cwd is accepted as projectRoot even when never saved or indexed`() = runTest { + val sessionCwd = tempRoot() + val payload = JSONObject().put("path", "docs/plan.md").put("projectRoot", sessionCwd.path) + val response = handleDocCommentsMessage("docComments:list", payload, tempRoot(), listOf(sessionCwd.path))!! + assertEquals(true, response.getBoolean("ok")) + } +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsDispatchTest.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsDispatchTest.kt new file mode 100644 index 000000000..6e2eb302e --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsDispatchTest.kt @@ -0,0 +1,414 @@ +// Pins T4 of the doc-comments build (docs/active/specs/2026-09-26-doc- +// comments-build-design.md §1.1, §3.2a, §4.3a): the by-extension dispatch +// point between the plain-text sidecar store and T16/T18's native docx/xlsx +// readers, plus the honest "not yet supported" refusal for native writes +// until T17/T19 land (see DocCommentsDispatch.kt's own header for why that +// split is correct here, not a gap in this task). +package com.youcoded.app.doccomments + +import com.youcoded.app.config.WorkingDir +import com.youcoded.app.config.WorkingDirStore +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.File +import java.nio.file.Files +import java.util.zip.CRC32 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +private fun fixtureFile(name: String): File { + val resourceStream = object {}.javaClass.getResourceAsStream("/doc-comments/$name") + ?: fail("missing test resource doc-comments/$name") + val tmp = Files.createTempFile("ycd-dispatch-", "-$name").toFile() + tmp.deleteOnExit() + resourceStream.use { input -> tmp.outputStream().use { output -> input.copyTo(output) } } + return tmp +} + +// F2 (T17 implementation review) fixture-building constants — top-level +// `const val` (Kotlin only allows `const` outside a class body). +private const val W_NS_ATTR = """xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"""" +private const val W14_NS_ATTR = """xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"""" + +class DocCommentsDispatchTest { + + @Test + fun `nativeFormatFor recognizes docx and xlsx by extension only`() { + assertEquals(NativeFormat.DOCX, nativeFormatFor("docs/plan.docx")) + assertEquals(NativeFormat.XLSX, nativeFormatFor("reports/q3.xlsx")) + assertNull(nativeFormatFor("docs/plan.md")) + assertNull(nativeFormatFor("docs/plan")) + // Case-insensitive, matching path.extname().toLowerCase() on desktop. + assertEquals(NativeFormat.DOCX, nativeFormatFor("DOCS/PLAN.DOCX")) + } + + @Test + // T19: xlsx write is now real too (docx already was, T17) — + // refuseNativeMutation no longer refuses ANY native format, mirroring + // desktop's own `refuseNativeMutation` (permanently null there since both + // its formats shipped). + fun `refuseNativeMutation refuses nothing now that both native formats write for real`() { + assertTrue(!refuseNativeMutation("docs/plan.docx")) + assertTrue(!refuseNativeMutation("reports/q3.xlsx")) + assertTrue(!refuseNativeMutation("docs/plan.md")) + } + + @Test + fun `listNativeComments dispatches a docx target to T16's real Kotlin reader`() { + val docx = fixtureFile("launch-brief.docx") + val home = Files.createTempDirectory("ycd-dispatch-home-").toFile().apply { deleteOnExit() } + // No projectRoot — the absolute-path fallback branch. F4 + // (implementation review) switched this branch from a denylist to an + // allowlist, so the fixture's own directory must now be registered + // as a known root for this to reach T16's reader at all — otherwise + // this test would exercise the access-control gate, not the reader. + WorkingDirStore(home).add(WorkingDir(label = "T16 fixture", path = docx.parentFile!!.canonicalPath)) + val result = listNativeComments(NativeFormat.DOCX, docx.absolutePath, null, home) + assertTrue(result is NativeListResult.Ok, "expected Ok, got $result") + assertTrue((result as NativeListResult.Ok).comments.isNotEmpty()) + } + + // T18 (redesigned 2026-09-27, threaded-only): `q3-sales-by-rep.xlsx` is a + // LEGACY-NOTES-only fixture — the retired reader this file used to hold + // would have found comments in it, but §4.1's own decision is that Notes + // are never shown any more, so the real threaded-comments reader now + // returns ZERO comments for it (a dedicated assertion of that, not just + // silently switching fixtures, since it's exactly the regression a + // careless "keep the old fixture" port could reintroduce). + @Test + fun `listNativeComments returns zero comments for a legacy-Notes-only xlsx (never the retired reader's output)`() { + val xlsx = fixtureFile("q3-sales-by-rep.xlsx") + val home = Files.createTempDirectory("ycd-dispatch-home-").toFile().apply { deleteOnExit() } + WorkingDirStore(home).add(WorkingDir(label = "legacy-notes fixture", path = xlsx.parentFile!!.canonicalPath)) + val result = listNativeComments(NativeFormat.XLSX, xlsx.absolutePath, null, home) + assertTrue(result is NativeListResult.Ok, "expected Ok, got $result") + assertEquals(emptyList(), (result as NativeListResult.Ok).comments) + } + + @Test + fun `listNativeComments dispatches a real threaded-comments xlsx target to T18's real Kotlin reader`() { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = Files.createTempDirectory("ycd-dispatch-home-").toFile().apply { deleteOnExit() } + WorkingDirStore(home).add(WorkingDir(label = "T18 fixture", path = xlsx.parentFile!!.canonicalPath)) + val result = listNativeComments(NativeFormat.XLSX, xlsx.absolutePath, null, home) + assertTrue(result is NativeListResult.Ok, "expected Ok, got $result") + assertTrue((result as NativeListResult.Ok).comments.isNotEmpty()) + } + + @Test + fun `a sensitive no-projectRoot path is refused before ever reaching the native reader`() { + val home = Files.createTempDirectory("ycd-dispatch-home2-").toFile().apply { deleteOnExit() } + val sshDir = File(home, ".ssh").apply { mkdirs() } + val fakeKey = File(sshDir, "id_rsa.docx").apply { writeText("not a real docx") } + val result = listNativeComments(NativeFormat.DOCX, fakeKey.absolutePath, null, home) + assertEquals(NativeListResult.Err("path-not-tracked"), result) + } + + // ── F4 (implementation review, major/security) — allowlist, not a denylist ── + @Test + fun `an untracked path outside every known root is refused even though it is not on the sensitive denylist`() { + val home = Files.createTempDirectory("ycd-dispatch-untracked-").toFile().apply { deleteOnExit() } + // Registered nowhere as a root and not a tracked artifact — before F4 + // this would have been allowed straight through (it matches nothing + // on the old denylist), which was the vulnerability the fix closes. + val docx = fixtureFile("launch-brief.docx") + val result = listNativeComments(NativeFormat.DOCX, docx.absolutePath, null, home) + assertEquals(NativeListResult.Err("path-not-tracked"), result) + } + + @Test + fun `a tracked external artifact dispatches to the reader even though it lives outside every known root`() { + val home = Files.createTempDirectory("ycd-dispatch-tracked-home-").toFile().apply { deleteOnExit() } + val project = Files.createTempDirectory("ycd-dispatch-tracked-project-").toFile().apply { deleteOnExit() } + WorkingDirStore(home).add(WorkingDir(label = "P", path = project.path)) + val docx = fixtureFile("launch-brief.docx") + com.youcoded.app.artifacts.appendVersion( + projectRoot = project.path, + projectId = "proj-tracked", + projectName = "P", + input = com.youcoded.app.artifacts.AppendVersionInput( + path = com.youcoded.app.artifacts.canonicalize(docx.absolutePath, null), + kind = "external", + absolutePath = com.youcoded.app.artifacts.canonicalize(docx.absolutePath, null), + sessionId = "s1", + type = "read", + author = "user", + ), + ) + val result = listNativeComments(NativeFormat.DOCX, docx.absolutePath, null, home) + assertTrue(result is NativeListResult.Ok, "expected Ok, got $result") + } + + // ── F3 (implementation review, major) — exception boundary ───────────── + @Test + fun `a corrupt-but-openable docx archive refuses invalid-docx instead of throwing past this function`() { + val home = Files.createTempDirectory("ycd-dispatch-corrupt-").toFile().apply { deleteOnExit() } + val docxDir = Files.createTempDirectory("ycd-dispatch-corrupt-docx-").toFile().apply { deleteOnExit() } + WorkingDirStore(home).add(WorkingDir(label = "corrupt", path = docxDir.path)) + val fakeDocx = File(docxDir, "broken.docx") + // A REAL, openable zip (a valid central directory) whose + // word/comments.xml entry is not valid XML at all — the "corrupt- + // but-openable archive" shape F3 exists for: ZipFile opens fine, + // parsing the entry inside is what throws. + java.util.zip.ZipOutputStream(fakeDocx.outputStream()).use { zip -> + zip.putNextEntry(java.util.zip.ZipEntry("word/document.xml")) + zip.write("".toByteArray()) + zip.closeEntry() + zip.putNextEntry(java.util.zip.ZipEntry("word/comments.xml")) + zip.write("this is not xml at all <<<".toByteArray()) + zip.closeEntry() + } + val result = listNativeComments(NativeFormat.DOCX, fakeDocx.absolutePath, null, home) + assertEquals(NativeListResult.Err("invalid-docx"), result) + } + + @Test + fun `a missing file after containment passes refuses read-failed`() { + val home = Files.createTempDirectory("ycd-dispatch-missing-").toFile().apply { deleteOnExit() } + val project = Files.createTempDirectory("ycd-dispatch-missing-project-").toFile().apply { deleteOnExit() } + WorkingDirStore(home).add(WorkingDir(label = "P", path = project.path)) + val neverCreated = File(project, "gone.docx") + val result = listNativeComments(NativeFormat.DOCX, neverCreated.absolutePath, null, home) + assertEquals(NativeListResult.Err("read-failed"), result) + } + + // ── F2 (T17 implementation review, major/crash risk) — write-path + // exception boundary. Mirrors F3's own read-path fixture shape above: a + // REAL, openable zip (valid central directory) whose CONTENT is corrupt + // in a way `loadArchiveForWrite`'s own two specific catches + // (`DocxUnsafeXmlDoctypeException`/`ZipBombDetectedException`) don't + // cover — before this fix, an exception here escaped every + // addNativeDocxComment/replyToNativeDocxComment/etc. uncaught, which + // would kill the coroutine `serviceScope.launch` runs it in with no + // handler installed — a process crash on Android, not a typed refusal. ── + + private fun textSelector(exact: String): CommentSelector.Text = + CommentSelector.Text(TextQuoteSelector(exact = exact, prefix = "", suffix = "", occurrence = 0)) + + /** A real, openable zip whose `word/comments.xml` entry is present but is + * not valid XML at all (no DOCTYPE — that's `DocxUnsafeXmlDoctypeException`'s + * own, already-caught case) — `parseXml` throws a plain `SAXException` + * here, which `loadArchiveForWrite`'s own catch block does NOT list. */ + private fun buildDocxWithMalformedCommentsXml(): File { + val dir = Files.createTempDirectory("ycd-dispatch-corrupt-write-").toFile().apply { deleteOnExit() } + val target = File(dir, "malformed-comments.docx") + ZipOutputStream(target.outputStream()).use { zos -> + fun entry(name: String, content: String) { + zos.putNextEntry(ZipEntry(name)) + zos.write(content.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + entry( + "[Content_Types].xml", + """""", + ) + entry( + "_rels/.rels", + """""", + ) + entry( + "word/_rels/document.xml.rels", + """""", + ) + entry( + "word/document.xml", + """""" + + """A commented sentence.""" + + """An uncommented sentence to add a NEW comment on.""" + + """""", + ) + // The corrupt part: real bytes, a real entry, but not XML at all + // — never a truncated/absent entry (that's `MISSING_DOCUMENT_PART`, + // an already-typed, already-tested case). + entry("word/comments.xml", "this is not xml at all <<<") + } + return target + } + + /** A real, openable zip whose `word/comments.xml` entry declares a CRC + * that does not match its own (otherwise well-formed) bytes — the + * "truncated/corrupted compressed entry" shape this finding names: + * `ZipFile.getInputStream(entry)` throws a plain `java.util.zip.ZipException` + * (CRC mismatch) while `readEntryBounded` reads it, well past + * `loadArchiveForWrite`'s own two specific catches. */ + private fun buildDocxWithCorruptCommentsPartCrc(): File { + val dir = Files.createTempDirectory("ycd-dispatch-corrupt-crc-").toFile().apply { deleteOnExit() } + val target = File(dir, "corrupt-crc.docx") + val commentsBytes = ( + """""" + + """Note.""" + + """""" + ).toByteArray(Charsets.UTF_8) + ZipOutputStream(target.outputStream()).use { zos -> + fun entry(name: String, content: String) { + zos.putNextEntry(ZipEntry(name)) + zos.write(content.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + entry( + "[Content_Types].xml", + """""", + ) + entry( + "_rels/.rels", + """""", + ) + entry( + "word/_rels/document.xml.rels", + """""", + ) + entry( + "word/document.xml", + """""" + + """A commented sentence.""" + + """""", + ) + // STORED (never DEFLATED) so the on-disk bytes ARE the plaintext + // XML — corruptible in place below with no compression algorithm + // involved. Size/CRC are declared against the ORIGINAL, correct + // bytes; the corruption step below only mutates content bytes + // in place (same length), so the zip's own structure (local + // header, central directory, offsets) stays entirely valid — + // only this one entry's CONTENT vs. its own declared CRC + // disagrees, exactly "corrupt but openable." + val commentsEntry = ZipEntry("word/comments.xml") + commentsEntry.method = ZipEntry.STORED + commentsEntry.size = commentsBytes.size.toLong() + commentsEntry.compressedSize = commentsBytes.size.toLong() + val crc32 = CRC32() + crc32.update(commentsBytes) + commentsEntry.crc = crc32.value + zos.putNextEntry(commentsEntry) + zos.write(commentsBytes) + zos.closeEntry() + } + // Corrupt the STORED comments.xml payload IN PLACE (same byte count, + // so no offset in the zip's own structure moves) — find its unique + // marker text and flip it to something else the CRC no longer + // matches. `document.xml` above carries no `w:comment` substring of + // its own, so this search is unambiguous. + val bytes = target.readBytes() + val marker = "w:comment w:id".toByteArray(Charsets.UTF_8) + var idx = -1 + outer@ for (i in 0..(bytes.size - marker.size)) { + for (j in marker.indices) if (bytes[i + j] != marker[j]) continue@outer + idx = i + break + } + assertTrue(idx >= 0, "test fixture bug: marker not found in the STORED comments.xml payload") + for (i in idx until idx + marker.size) bytes[i] = 'X'.code.toByte() + target.writeBytes(bytes) + return target + } + + @Test + fun `addNativeDocxComment refuses invalid-docx instead of crashing when comments xml is not well-formed XML`() = runTest { + val home = Files.createTempDirectory("ycd-dispatch-corrupt-write-home-").toFile().apply { deleteOnExit() } + val docx = buildDocxWithMalformedCommentsXml() + WorkingDirStore(home).add(WorkingDir(label = "corrupt-write", path = docx.parentFile!!.canonicalPath)) + val before = docx.readBytes() + val result = addNativeDocxComment( + docx.absolutePath, null, + textSelector("An uncommented sentence to add a NEW comment on."), + "x", "user", home, + ) + assertEquals(NativeMutateResult.Err(DocxWriteError.INVALID_DOCX.wire), result) + assertTrue(docx.readBytes().contentEquals(before)) // target left byte-identical + } + + @Test + fun `resolveNativeDocxComment refuses invalid-docx instead of crashing when comments xml is not well-formed XML`() = runTest { + // Same corrupt fixture, a DIFFERENT dispatch function — proves the + // boundary is applied uniformly across every write entry point, not + // just `add`. + val home = Files.createTempDirectory("ycd-dispatch-corrupt-write-home2-").toFile().apply { deleteOnExit() } + val docx = buildDocxWithMalformedCommentsXml() + WorkingDirStore(home).add(WorkingDir(label = "corrupt-write", path = docx.parentFile!!.canonicalPath)) + val before = docx.readBytes() + val result = resolveNativeDocxComment(docx.absolutePath, null, "w-0", home) + assertEquals(NativeMutateResult.Err(DocxWriteError.INVALID_DOCX.wire), result) + assertTrue(docx.readBytes().contentEquals(before)) + } + + @Test + fun `moveNativeDocxComment refuses invalid-docx instead of crashing when comments xml is a corrupted (bad-CRC) entry`() = runTest { + val home = Files.createTempDirectory("ycd-dispatch-corrupt-crc-home-").toFile().apply { deleteOnExit() } + val docx = buildDocxWithCorruptCommentsPartCrc() + WorkingDirStore(home).add(WorkingDir(label = "corrupt-crc", path = docx.parentFile!!.canonicalPath)) + val before = docx.readBytes() + val result = moveNativeDocxComment(docx.absolutePath, null, "w-0", textSelector("A commented sentence."), home) + assertEquals(NativeMutateResult.Err(DocxWriteError.INVALID_DOCX.wire), result) + assertTrue(docx.readBytes().contentEquals(before)) + } + + // ── Edit/delete build (2026-09-28, design doc §"Edit and delete") ─────── + + @Test + fun `editNativeDocxComment and deleteNativeDocxComment dispatch to the real T17 write pipeline`() = runTest { + val home = Files.createTempDirectory("ycd-dispatch-edit-").toFile().apply { deleteOnExit() } + val docx = fixtureFile("launch-brief.docx") + WorkingDirStore(home).add(WorkingDir(label = "edit-delete fixture", path = docx.parentFile!!.canonicalPath)) + + val edited = editNativeDocxComment(docx.absolutePath, null, "w-1", "edited via dispatch", home) + assertTrue(edited is NativeMutateResult.Ok, "expected Ok, got $edited") + assertEquals("edited via dispatch", (edited as NativeMutateResult.Ok).value) + + val deleted = deleteNativeDocxComment(docx.absolutePath, null, "w-0", home) + assertTrue(deleted is NativeMutateResult.Ok, "expected Ok, got $deleted") + + val relisted = listNativeComments(NativeFormat.DOCX, docx.absolutePath, null, home) + assertTrue(relisted is NativeListResult.Ok, "expected Ok, got $relisted") + val comments = (relisted as NativeListResult.Ok).comments + assertTrue(comments.none { it.id == "w-0" }) + assertEquals("edited via dispatch", comments.find { it.id == "w-1" }?.text) + } + + @Test + fun `editNativeXlsxComment and deleteNativeXlsxComment dispatch to the real T19 write pipeline`() = runTest { + val home = Files.createTempDirectory("ycd-dispatch-edit-xlsx-").toFile().apply { deleteOnExit() } + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + WorkingDirStore(home).add(WorkingDir(label = "edit-delete xlsx fixture", path = xlsx.parentFile!!.canonicalPath)) + val before = listNativeComments(NativeFormat.XLSX, xlsx.absolutePath, null, home) + assertTrue(before is NativeListResult.Ok, "expected Ok, got $before") + val f7 = (before as NativeListResult.Ok).comments.find { + it.selector is CommentSelector.Cell && (it.selector as CommentSelector.Cell).selector.cell == "F7" + } + assertTrue(f7 != null, "expected the F7 fixture thread") + + val edited = editNativeXlsxComment(xlsx.absolutePath, null, f7!!.id, "edited via dispatch", home) + assertTrue(edited is NativeMutateResult.Ok, "expected Ok, got $edited") + + val deleted = deleteNativeXlsxComment(xlsx.absolutePath, null, f7.id, home) + assertTrue(deleted is NativeMutateResult.Ok, "expected Ok, got $deleted") + + val relisted = listNativeComments(NativeFormat.XLSX, xlsx.absolutePath, null, home) + assertTrue(relisted is NativeListResult.Ok, "expected Ok, got $relisted") + assertTrue((relisted as NativeListResult.Ok).comments.none { it.id == f7.id }) + } + + @Test + fun `editNativeDocxComment refuses invalid-docx instead of crashing when comments xml is not well-formed XML`() = runTest { + val home = Files.createTempDirectory("ycd-dispatch-corrupt-edit-").toFile().apply { deleteOnExit() } + val docx = buildDocxWithMalformedCommentsXml() + WorkingDirStore(home).add(WorkingDir(label = "corrupt-edit", path = docx.parentFile!!.canonicalPath)) + val before = docx.readBytes() + val result = editNativeDocxComment(docx.absolutePath, null, "w-0", "x", home) + assertEquals(NativeMutateResult.Err(DocxWriteError.INVALID_DOCX.wire), result) + assertTrue(docx.readBytes().contentEquals(before)) + } + + @Test + fun `deleteNativeDocxComment refuses invalid-docx instead of crashing when comments xml is a corrupted (bad-CRC) entry`() = runTest { + val home = Files.createTempDirectory("ycd-dispatch-corrupt-delete-").toFile().apply { deleteOnExit() } + val docx = buildDocxWithCorruptCommentsPartCrc() + WorkingDirStore(home).add(WorkingDir(label = "corrupt-delete", path = docx.parentFile!!.canonicalPath)) + val before = docx.readBytes() + val result = deleteNativeDocxComment(docx.absolutePath, null, "w-0", home) + assertEquals(NativeMutateResult.Err(DocxWriteError.INVALID_DOCX.wire), result) + assertTrue(docx.readBytes().contentEquals(before)) + } +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsGateTest.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsGateTest.kt new file mode 100644 index 000000000..be776605f --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsGateTest.kt @@ -0,0 +1,152 @@ +// Pins T4 of the doc-comments build (docs/active/specs/2026-09-26-doc- +// comments-build-design.md §1.5): Android's own "is this projectRoot a root +// the app recognizes" gate — the Kotlin port of desktop's doc-comments- +// gate.ts's `refuseUnknownProjectRoot`, built on Android's OWN known-root +// sources (WorkingDirStore's saved working directories + CentralIndex's +// indexed projects + the caller's own live session cwds) rather than +// desktop's Electron-only `isKnownRoot()`. +package com.youcoded.app.doccomments + +import com.youcoded.app.artifacts.CentralIndexProject +import com.youcoded.app.artifacts.IndexStats +import com.youcoded.app.artifacts.upsertProject +import com.youcoded.app.config.WorkingDirStore +import com.youcoded.app.config.WorkingDir +import org.junit.Test +import java.nio.file.Files +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private fun tempHome(): java.io.File = Files.createTempDirectory("ycd-doccomments-gate-").toFile().apply { deleteOnExit() } + +class DocCommentsGateTest { + + @Test + fun `an unrecognized projectRoot is refused`() { + val home = tempHome() + val unknown = Files.createTempDirectory("ycd-doccomments-unknown-").toFile().apply { deleteOnExit() } + assertTrue(refuseUnknownProjectRoot(unknown.path, home, java.io.File(home, ".claude"))) + } + + @Test + fun `a caller-named root of the filesystem root does not become a no-op allow`() { + val home = tempHome() + // The exact forged-root shape the T3 build review's own blocker (F1) + // called out for desktop: '/' (or a home directory) must not vouch + // for itself just by being a real, existing directory. + assertTrue(refuseUnknownProjectRoot("/", home, java.io.File(home, ".claude"))) + } + + @Test + fun `no projectRoot at all is never refused — the fallback path is gated elsewhere`() { + val home = tempHome() + assertFalse(refuseUnknownProjectRoot(null, home, java.io.File(home, ".claude"))) + assertFalse(refuseUnknownProjectRoot("", home, java.io.File(home, ".claude"))) + } + + @Test + fun `a saved working directory is a known root`() { + val home = tempHome() + val project = Files.createTempDirectory("ycd-doccomments-saved-").toFile().apply { deleteOnExit() } + WorkingDirStore(home).add(WorkingDir(label = "My Project", path = project.path)) + assertFalse(refuseUnknownProjectRoot(project.path, home, java.io.File(home, ".claude"))) + } + + @Test + fun `an indexed central-index project is a known root`() { + val home = tempHome() + val claudeDir = java.io.File(home, ".claude") + val project = Files.createTempDirectory("ycd-doccomments-indexed-").toFile().apply { deleteOnExit() } + upsertProject( + claudeDir.path, + CentralIndexProject( + id = "proj-1", + name = "Indexed Project", + path = project.canonicalPath, + lastIndexed = "2026-09-26T00:00:00.000Z", + lastSession = null, + contentTypes = listOf("artifacts"), + stats = IndexStats(artifactCount = 0), + ), + ) + assertFalse(refuseUnknownProjectRoot(project.path, home, claudeDir)) + } + + @Test + fun `a live session cwd counts as known even when never saved or indexed`() { + val home = tempHome() + val sessionCwd = Files.createTempDirectory("ycd-doccomments-session-").toFile().apply { deleteOnExit() } + assertTrue(refuseUnknownProjectRoot(sessionCwd.path, home, java.io.File(home, ".claude"))) + assertFalse(refuseUnknownProjectRoot(sessionCwd.path, home, java.io.File(home, ".claude"), listOf(sessionCwd.path))) + } + + // ── F4 (T4 implementation review, major/security) — allowUntrackedNativeRead ── + + @Test + fun `a path outside every known root with nothing tracking it is refused, not silently allowed`() { + val home = tempHome() + val outside = Files.createTempDirectory("ycd-doccomments-untracked-").toFile().apply { deleteOnExit() } + val file = java.io.File(outside, "f.docx").apply { writeText("x") } + // Before F4 this path would have been ALLOWED — it matches nothing on + // the old denylist. That was the vulnerability this fix closes. + assertFalse(allowUntrackedNativeRead(file.path, home, java.io.File(home, ".claude"))) + } + + @Test + fun `a path under a known project root is allowed`() { + val home = tempHome() + val project = Files.createTempDirectory("ycd-doccomments-root-").toFile().apply { deleteOnExit() } + WorkingDirStore(home).add(WorkingDir(label = "P", path = project.path)) + val file = java.io.File(project, "f.docx").apply { writeText("x") } + assertTrue(allowUntrackedNativeRead(file.path, home, java.io.File(home, ".claude"))) + } + + @Test + fun `a sensitive path is refused even when it sits inside an otherwise-known root`() { + val home = tempHome() + val project = Files.createTempDirectory("ycd-doccomments-root2-").toFile().apply { deleteOnExit() } + WorkingDirStore(home).add(WorkingDir(label = "P", path = project.path)) + val sshDir = java.io.File(project, ".ssh").apply { mkdirs() } + val file = java.io.File(sshDir, "id_rsa.docx").apply { writeText("x") } + assertFalse(allowUntrackedNativeRead(file.path, home, java.io.File(home, ".claude"))) + } + + @Test + fun `a live session cwd is NOT enough on its own — allowUntrackedNativeRead excludes session roots`() { + val home = tempHome() + // allowUntrackedNativeRead never receives extraSessionRoots at all + // (unlike refuseUnknownProjectRoot) — a live session's own cwd must + // NOT vouch for a raw-bytes read the way it vouches for a + // projectRoot, per the phone-can-start-a-session-anywhere reasoning + // this function's own doc comment cites. + val sessionCwd = Files.createTempDirectory("ycd-doccomments-session2-").toFile().apply { deleteOnExit() } + val file = java.io.File(sessionCwd, "f.docx").apply { writeText("x") } + assertFalse(allowUntrackedNativeRead(file.path, home, java.io.File(home, ".claude"))) + } + + @Test + fun `a tracked external artifact is allowed even though it lives outside every known root`() { + val home = tempHome() + val claudeDir = java.io.File(home, ".claude") + val project = Files.createTempDirectory("ycd-doccomments-tracked-project-").toFile().apply { deleteOnExit() } + WorkingDirStore(home).add(WorkingDir(label = "P", path = project.path)) + val outside = Files.createTempDirectory("ycd-doccomments-tracked-outside-").toFile().apply { deleteOnExit() } + val externalFile = java.io.File(outside, "report.xlsx").apply { writeText("x") } + + com.youcoded.app.artifacts.appendVersion( + projectRoot = project.path, + projectId = "proj-tracked", + projectName = "P", + input = com.youcoded.app.artifacts.AppendVersionInput( + path = com.youcoded.app.artifacts.canonicalize(externalFile.path, null), + kind = "external", + absolutePath = com.youcoded.app.artifacts.canonicalize(externalFile.path, null), + sessionId = "s1", + type = "read", + author = "user", + ), + ) + + assertTrue(allowUntrackedNativeRead(externalFile.path, home, claudeDir)) + } +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsPendingQueueTest.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsPendingQueueTest.kt new file mode 100644 index 000000000..39a1f3781 --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsPendingQueueTest.kt @@ -0,0 +1,552 @@ +// Pins T20 of the doc-comments build (docs/active/specs/2026-09-26-doc- +// comments-build-design.md §9.2): Android's half of the docx/xlsx pending- +// mutation queue — a Kotlin coroutine polling loop standing in for desktop's +// chokidar watcher (pending-mutation-queue.ts), applying a request through +// the SAME dispatch functions (DocCommentsDispatch.kt) the docComments:* +// bridge already uses. Mirrors desktop's own pending-mutation-queue.test.ts +// scenario for scenario: round-trip apply, a move request, token +// authorization (finding #1), an unrecognized `kind` (finding #3), a +// pre-planted/back-dated request never firing (finding #1, defense in +// depth), and orphaned .result.json sweeping (finding #4). +package com.youcoded.app.doccomments + +import org.json.JSONObject +import org.junit.After +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private const val W_NS = """xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"""" +private const val W14_NS = """xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"""" + +/** A minimal, valid, comments.xml-free .docx with two known sentences — one + * to add a comment on, one to move it to. No comments.xml part at all + * (mirrors T16/T17's own "a docx with no comments.xml part doesn't crash" + * coverage) — the queue's first "add" request is what creates it. */ +private fun buildMinimalDocx(dir: File, name: String = "queue-fixture.docx"): File { + val target = File(dir, name) + java.util.zip.ZipOutputStream(target.outputStream()).use { zos -> + fun entry(entryName: String, content: String) { + zos.putNextEntry(java.util.zip.ZipEntry(entryName)) + zos.write(content.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + entry( + "[Content_Types].xml", + """""", + ) + entry( + "_rels/.rels", + """""", + ) + entry( + "word/_rels/document.xml.rels", + """""", + ) + entry( + "word/document.xml", + """""" + + """An uncommented sentence to add a NEW comment on.""" + + """A destination sentence for a moved comment.""" + + """""", + ) + } + return target +} + +private fun tempDir(prefix: String): File = Files.createTempDirectory(prefix).toFile().apply { deleteOnExit() } + +/** Waits on the thing itself (a file's existence) rather than a fixed sleep — + * test-suite-hygiene's own rule. Bounded so a genuine bug fails fast instead + * of hanging the suite. */ +private fun waitFor(timeoutMs: Long = 5000, intervalMs: Long = 20, condition: () -> Boolean): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (condition()) return true + Thread.sleep(intervalMs) + } + return condition() +} + +private fun addRequestJson(id: String, path: String, token: String, projectRootLie: String = "/nonsense"): JSONObject = + JSONObject() + .put("id", id) + .put("kind", "add") + .put("format", "docx") + .put("path", path) + .put("projectRoot", projectRootLie) // never trusted — see this file's own header + .put("token", token) + .put("createdAt", System.currentTimeMillis()) + .put("text", "a queued comment") + .put("author", "assistant") + .put( + "selector", + JSONObject().put("kind", "text").put( + "selector", + JSONObject().put("type", "TextQuoteSelector") + .put("exact", "An uncommented sentence to add a NEW comment on.") + .put("prefix", "").put("suffix", "").put("occurrence", 0), + ), + ) + +class DocCommentsPendingQueueTest { + + @After + fun tearDown() { + DocCommentsPendingQueue.resetForTest() + } + + private fun startQueue(project: File, home: File, sessionId: String = "session-1", token: String = "tok-$sessionId") { + DocCommentsPendingQueue.start(sessionId, project.absolutePath, home, File(home, ".claude"), token) + } + + private fun pendingDir(project: File): File = File(project, ".youcoded/comments/.pending").apply { mkdirs() } + + private fun writeRequest(pendingDir: File, id: String, json: JSONObject) { + val f = File(pendingDir, "$id.json") + val tmp = File(pendingDir, "$id.json.tmp") + tmp.writeText(json.toString()) + tmp.renameTo(f) // atomic-ish rename, mirroring the real MCP script's own submit + } + + @Test + fun `an add request lands a real comment and the request file is removed after`() { + val project = tempDir("ycd-queue-add-") + val home = tempDir("ycd-queue-add-home-") + buildMinimalDocx(project) + startQueue(project, home) + try { + val pending = pendingDir(project) + val token = "tok-session-1" + writeRequest(pending, "req-1", addRequestJson("req-1", "queue-fixture.docx", token)) + + val resultFile = File(pending, "req-1.result.json") + assertTrue(waitFor { resultFile.exists() }, "result file never appeared") + val result = JSONObject(resultFile.readText()) + assertTrue(result.optBoolean("ok"), "expected ok:true, got $result") + assertTrue(result.has("id")) + + assertTrue(waitFor { !File(pending, "req-1.json").exists() }, "request file was never cleaned up") + + val comments = listNativeComments(NativeFormat.DOCX, File(project, "queue-fixture.docx").absolutePath, project.absolutePath, home) + assertTrue(comments is NativeListResult.Ok) + assertEquals(1, comments.comments.size) + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `a move request round-trips through the queue against the shared fixture`() { + val project = tempDir("ycd-queue-move-") + val home = tempDir("ycd-queue-move-home-") + val docx = buildMinimalDocx(project) + startQueue(project, home) + try { + val pending = pendingDir(project) + val token = "tok-session-1" + + // First, add — synchronously via the real dispatch (not the + // queue) so we know the exact comment id to move. + val added = kotlinx.coroutines.runBlocking { + addNativeDocxComment( + docx.absolutePath, project.absolutePath, + CommentSelector.Text(TextQuoteSelector("An uncommented sentence to add a NEW comment on.", "", "", 0)), + "hello", "user", home, + ) + } + assertTrue(added is NativeMutateResult.Ok) + val commentId = added.value + + val moveReq = JSONObject() + .put("id", "req-move") + .put("kind", "move") + .put("format", "docx") + .put("path", "queue-fixture.docx") + .put("projectRoot", "/nonsense") + .put("token", token) + .put("createdAt", System.currentTimeMillis()) + .put("commentId", commentId) + .put( + "newSelector", + JSONObject().put("kind", "text").put( + "selector", + JSONObject().put("type", "TextQuoteSelector") + .put("exact", "A destination sentence for a moved comment.") + .put("prefix", "").put("suffix", "").put("occurrence", 0), + ), + ) + writeRequest(pending, "req-move", moveReq) + + val resultFile = File(pending, "req-move.result.json") + assertTrue(waitFor { resultFile.exists() }, "move result never appeared") + val result = JSONObject(resultFile.readText()) + assertTrue(result.optBoolean("ok"), "expected ok:true, got $result") + // Code review 2026-09-27, Android F2 (fixed alongside the stale + // WHY comment on moveNativeXlsxComment): a docx move's id never + // changes, so — unlike the xlsx case pinned below — this result + // carries no `id` at all. + assertFalse(result.has("id")) + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + /** `q3-sales-by-rep.xlsx`'s own genuine legacy Notes sit at B2/B18/B19/B20 + * on this sheet (xl/comments1.xml) — D10 is deliberately NOT one of + * them, same caveat as desktop's matching test. */ + private fun xlsxFixtureFile(name: String): File { + val resourceStream = object {}.javaClass.getResourceAsStream("/doc-comments/$name") + ?: error("missing test resource doc-comments/$name") + val tmp = Files.createTempFile("ycd-queue-xlsx-", "-$name").toFile() + tmp.deleteOnExit() + resourceStream.use { input -> tmp.outputStream().use { output -> input.copyTo(output) } } + return tmp + } + + // Code review 2026-09-27, Android F2 / desktop F1 parity: mirrors + // desktop's pending-mutation-queue.test.ts's own "an xlsx move forwards + // the fresh id in the result (F1 fix)" — pins that + // DocCommentsPendingQueue.kt's `move` branch (fixed alongside the stale + // WHY comment above `moveNativeXlsxComment`) now forwards the fresh id, + // matching desktop's own queue and keeping the two platforms' MCP-facing + // behavior in sync. + @Test + fun `an xlsx move forwards the fresh id in the result`() { + val project = tempDir("ycd-queue-move-xlsx-") + val home = tempDir("ycd-queue-move-xlsx-home-") + val fixture = xlsxFixtureFile("q3-sales-by-rep.xlsx") + val target = File(project, "q3.xlsx") + fixture.copyTo(target, overwrite = true) + startQueue(project, home) + try { + val pending = pendingDir(project) + val token = "tok-session-1" + + val added = kotlinx.coroutines.runBlocking { + addNativeXlsxComment( + target.absolutePath, project.absolutePath, + CommentSelector.Cell(CellSelector("A1", "Q3")), + "assistant note", "assistant", home, + ) + } + assertTrue(added is NativeMutateResult.Ok) + val oldId = added.value + assertTrue(oldId.startsWith("xt-")) + + val moveReq = JSONObject() + .put("id", "req-move-xlsx") + .put("kind", "move") + .put("format", "xlsx") + .put("path", "q3.xlsx") + .put("projectRoot", "/nonsense") + .put("token", token) + .put("createdAt", System.currentTimeMillis()) + .put("commentId", oldId) + .put( + "newSelector", + JSONObject().put("kind", "cell").put( + "selector", + JSONObject().put("type", "CellSelector").put("cell", "D10").put("sheet", "Q3"), + ), + ) + writeRequest(pending, "req-move-xlsx", moveReq) + + val resultFile = File(pending, "req-move-xlsx.result.json") + assertTrue(waitFor { resultFile.exists() }, "move result never appeared") + val result = JSONObject(resultFile.readText()) + assertTrue(result.optBoolean("ok"), "expected ok:true, got $result") + assertTrue(result.has("id"), "expected the fresh id to be forwarded, got $result") + assertTrue(result.getString("id").isNotEmpty()) + assertFalse(result.getString("id") == oldId, "the whole point of the fix: the id must be the FRESH one") + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `a request with the WRONG token is refused, never applied`() { + val project = tempDir("ycd-queue-badtoken-") + val home = tempDir("ycd-queue-badtoken-home-") + buildMinimalDocx(project) + startQueue(project, home, token = "the-real-token") + try { + val pending = pendingDir(project) + writeRequest(pending, "req-bad", addRequestJson("req-bad", "queue-fixture.docx", token = "forged-token")) + + val resultFile = File(pending, "req-bad.result.json") + assertTrue(waitFor { resultFile.exists() }) + val result = JSONObject(resultFile.readText()) + assertFalse(result.optBoolean("ok")) + assertEquals("invalid-request-token", result.optString("error")) + + val comments = listNativeComments(NativeFormat.DOCX, File(project, "queue-fixture.docx").absolutePath, project.absolutePath, home) + assertTrue(comments is NativeListResult.Ok) + assertEquals(0, comments.comments.size) + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `a request with NO token field at all is refused`() { + val project = tempDir("ycd-queue-notoken-") + val home = tempDir("ycd-queue-notoken-home-") + buildMinimalDocx(project) + startQueue(project, home, token = "the-real-token") + try { + val pending = pendingDir(project) + val req = addRequestJson("req-notoken", "queue-fixture.docx", token = "placeholder") + req.remove("token") + writeRequest(pending, "req-notoken", req) + + val resultFile = File(pending, "req-notoken.result.json") + assertTrue(waitFor { resultFile.exists() }) + assertFalse(JSONObject(resultFile.readText()).optBoolean("ok")) + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `a request carrying a DIFFERENT session's valid token, sharing the same project, is still accepted`() { + val project = tempDir("ycd-queue-sharedproject-") + val home = tempDir("ycd-queue-sharedproject-home-") + buildMinimalDocx(project) + DocCommentsPendingQueue.start("session-A", project.absolutePath, home, File(home, ".claude"), "token-A") + DocCommentsPendingQueue.start("session-B", project.absolutePath, home, File(home, ".claude"), "token-B") + try { + val pending = pendingDir(project) + // Request carries session B's token — still valid, since both + // sessions share this one project's queue entry. + writeRequest(pending, "req-shared", addRequestJson("req-shared", "queue-fixture.docx", token = "token-B")) + + val resultFile = File(pending, "req-shared.result.json") + assertTrue(waitFor { resultFile.exists() }) + assertTrue(JSONObject(resultFile.readText()).optBoolean("ok")) + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `stopping only one of two refs still processes a request, stopping both stops processing`() { + val project = tempDir("ycd-queue-refcount-") + val home = tempDir("ycd-queue-refcount-home-") + buildMinimalDocx(project) + DocCommentsPendingQueue.start("session-A", project.absolutePath, home, File(home, ".claude"), "token-A") + DocCommentsPendingQueue.start("session-B", project.absolutePath, home, File(home, ".claude"), "token-B") + try { + DocCommentsPendingQueue.stop("session-A", project.absolutePath) + + val pending = pendingDir(project) + writeRequest(pending, "req-after-stop-one", addRequestJson("req-after-stop-one", "queue-fixture.docx", token = "token-B")) + val result1 = File(pending, "req-after-stop-one.result.json") + assertTrue(waitFor { result1.exists() }, "queue stopped processing after only ONE of two refs was dropped") + assertTrue(JSONObject(result1.readText()).optBoolean("ok")) + + DocCommentsPendingQueue.stop("session-B", project.absolutePath) + writeRequest(pending, "req-after-stop-both", addRequestJson("req-after-stop-both", "queue-fixture.docx", token = "token-B")) + val result2 = File(pending, "req-after-stop-both.result.json") + // Negative assertion: bounded wait, then assert absence — the + // suite's own "wait for the positive signal, then settle for the + // negative" rule, not a longer positive wait re-labeled. + assertFalse(waitFor(timeoutMs = 800) { result2.exists() }, "queue kept processing after BOTH refs were dropped") + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `an unrecognized kind never mutates anything and reports unknown-mutation-kind`() { + val project = tempDir("ycd-queue-badkind-") + val home = tempDir("ycd-queue-badkind-home-") + buildMinimalDocx(project) + startQueue(project, home, token = "tok") + try { + val pending = pendingDir(project) + val req = addRequestJson("req-badkind", "queue-fixture.docx", token = "tok") + req.put("kind", "explode") + writeRequest(pending, "req-badkind", req) + + val resultFile = File(pending, "req-badkind.result.json") + assertTrue(waitFor { resultFile.exists() }) + val result = JSONObject(resultFile.readText()) + assertFalse(result.optBoolean("ok")) + assertEquals("unknown-mutation-kind", result.optString("error")) + + val comments = listNativeComments(NativeFormat.DOCX, File(project, "queue-fixture.docx").absolutePath, project.absolutePath, home) + assertEquals(0, (comments as NativeListResult.Ok).comments.size) + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `a request missing kind entirely gets the same honest refusal`() { + val project = tempDir("ycd-queue-nokind-") + val home = tempDir("ycd-queue-nokind-home-") + buildMinimalDocx(project) + startQueue(project, home, token = "tok") + try { + val pending = pendingDir(project) + val req = addRequestJson("req-nokind", "queue-fixture.docx", token = "tok") + req.remove("kind") + writeRequest(pending, "req-nokind", req) + + val resultFile = File(pending, "req-nokind.result.json") + assertTrue(waitFor { resultFile.exists() }) + assertEquals("unknown-mutation-kind", JSONObject(resultFile.readText()).optString("error")) + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `garbage JSON in the pending directory is ignored, and a real request afterward still works`() { + val project = tempDir("ycd-queue-garbage-") + val home = tempDir("ycd-queue-garbage-home-") + buildMinimalDocx(project) + startQueue(project, home, token = "tok") + try { + val pending = pendingDir(project) + File(pending, "garbage.json").writeText("{ this is not json ][") + + // The garbage file is never answered (no trustworthy id to build a + // result path from). Since 2026-09-28 the queue claims a request + // (renames it) before reading it, so garbage is set aside rather + // than left to be re-read every tick — prove no answer appears, + // then that a REAL request still gets processed afterward. + assertTrue(waitFor { !File(pending, "garbage.json").exists() }, "garbage was never picked up") + assertFalse(File(pending, "garbage.result.json").exists()) + + writeRequest(pending, "req-after-garbage", addRequestJson("req-after-garbage", "queue-fixture.docx", token = "tok")) + val resultFile = File(pending, "req-after-garbage.result.json") + assertTrue(waitFor { resultFile.exists() }, "a real request after garbage JSON was never processed") + assertTrue(JSONObject(resultFile.readText()).optBoolean("ok")) + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `a pre-planted, back-dated request is never applied, even with a valid token`() { + val project = tempDir("ycd-queue-planted-") + val home = tempDir("ycd-queue-planted-home-") + buildMinimalDocx(project) + val pending = pendingDir(project) + // Plant the request BEFORE the queue starts, then back-date it well + // past FRESHNESS_MARGIN_MS — mirrors a `git clone` stamping a + // planted file's mtime as "now" at checkout time. + val plantedId = "req-planted" + writeRequest(pending, plantedId, addRequestJson(plantedId, "queue-fixture.docx", token = "tok")) + val planted = File(pending, "$plantedId.json") + assertTrue(planted.setLastModified(System.currentTimeMillis() - 60_000)) + + startQueue(project, home, token = "tok") + try { + val resultFile = File(pending, "$plantedId.result.json") + // Negative: bounded wait then assert absence. + assertFalse(waitFor(timeoutMs = 1000) { resultFile.exists() }, "a back-dated, pre-planted request was applied") + assertTrue(planted.exists(), "a refused request should be left in place, not silently deleted") + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `a request written just as the queue starts is still processed (a genuine cold-start race)`() { + val project = tempDir("ycd-queue-coldstart-") + val home = tempDir("ycd-queue-coldstart-home-") + buildMinimalDocx(project) + val pending = pendingDir(project) + val id = "req-coldstart" + writeRequest(pending, id, addRequestJson(id, "queue-fixture.docx", token = "tok")) + // No back-dating this time — its mtime is "now," within + // FRESHNESS_MARGIN_MS of the queue's own startedAt. + + startQueue(project, home, token = "tok") + try { + val resultFile = File(pending, "$id.result.json") + assertTrue(waitFor { resultFile.exists() }, "a genuine cold-start race request was never processed") + assertTrue(JSONObject(resultFile.readText()).optBoolean("ok")) + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `an orphaned result file older than the sweep threshold is removed on the next queue start`() { + val project = tempDir("ycd-queue-sweep-") + val home = tempDir("ycd-queue-sweep-home-") + buildMinimalDocx(project) + val pending = pendingDir(project) + val orphan = File(pending, "ancient.result.json") + orphan.writeText("""{"ok":true}""") + assertTrue(orphan.setLastModified(System.currentTimeMillis() - (2 * 60 * 60 * 1000))) // 2h old + + startQueue(project, home, token = "tok") + try { + assertTrue(waitFor { !orphan.exists() }, "a stale orphaned result file was never swept") + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + @Test + fun `a path outside the project is refused honestly, never applied`() { + val project = tempDir("ycd-queue-outside-") + val home = tempDir("ycd-queue-outside-home-") + startQueue(project, home, token = "tok") + try { + val pending = pendingDir(project) + val req = addRequestJson("req-outside", "../../etc/passwd", token = "tok") + writeRequest(pending, "req-outside", req) + + val resultFile = File(pending, "req-outside.result.json") + assertTrue(waitFor { resultFile.exists() }) + assertFalse(JSONObject(resultFile.readText()).optBoolean("ok")) + } finally { + DocCommentsPendingQueue.resetForTest() + } + } + + // 2026-09-28 PR review: leftover requests and claims piled up forever — + // only result files were swept. + @Test + fun `hour-old leftover requests and claims are swept on start, fresh ones are left alone`() { + val project = tempDir("ycd-queue-sweep-leftovers-") + val home = tempDir("ycd-queue-sweep-leftovers-home-") + val pending = pendingDir(project) + val old = System.currentTimeMillis() - 2 * 60 * 60 * 1000L + val staleRequest = File(pending, "stale.json").apply { writeText("{}"); setLastModified(old) } + val staleClaim = File(pending, "stale2.claimed").apply { writeText("{}"); setLastModified(old) } + val freshClaim = File(pending, "fresh.claimed").apply { writeText("{}") } + startQueue(project, home, token = "tok") + assertTrue(waitFor { !staleRequest.exists() && !staleClaim.exists() }, "leftovers were never swept") + assertTrue(freshClaim.exists()) + } + + // 2026-09-28 PR review: the queue ran in the scope of the FIRST session + // to start it, so closing that session stopped it for everyone sharing + // the project. `start` no longer takes a session scope at all; this pins + // the user-visible half: after the first session stops, the second's + // request is still applied. + @Test + fun `closing the first session keeps the queue working for a second session in the same project`() { + val project = tempDir("ycd-queue-first-closes-") + val home = tempDir("ycd-queue-first-closes-home-") + buildMinimalDocx(project) + DocCommentsPendingQueue.start("session-A", project.absolutePath, home, File(home, ".claude"), "token-A") + DocCommentsPendingQueue.start("session-B", project.absolutePath, home, File(home, ".claude"), "token-B") + DocCommentsPendingQueue.stop("session-A", project.absolutePath) + val pending = pendingDir(project) + writeRequest(pending, "req-b", addRequestJson("req-b", "queue-fixture.docx", token = "token-B")) + val resultFile = File(pending, "req-b.result.json") + assertTrue(waitFor { resultFile.exists() }, "queue stopped when the first session closed") + assertTrue(JSONObject(resultFile.readText()).optBoolean("ok")) + } +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsPermissionTest.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsPermissionTest.kt new file mode 100644 index 000000000..7d3427f57 --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsPermissionTest.kt @@ -0,0 +1,127 @@ +// Pins T20's "same permission behaviour in Android's auto-approve path" +// requirement (docs/active/specs/2026-09-26-doc-comments-build-design.md +// §5.2a, decided option 1) — the Kotlin port of desktop's permission-auto- +// approve.ts `shouldAutoApproveDocComment`, mirroring +// desktop/tests/permission-auto-approve.test.ts's own `shouldAutoApproveDocComment` +// describe block scenario for scenario. +package com.youcoded.app.doccomments + +import org.json.JSONObject +import org.junit.Test +import java.nio.file.Files +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private const val SERVER_ID = "youcoded-doc-comments-deadbeef" +private val MUTATOR_TOOL = DocCommentsMcpNames.toolName(SERVER_ID, "ReplyToComment") +private val READ_TOOL = DocCommentsMcpNames.readTool(SERVER_ID) + +private fun input(path: String): JSONObject = JSONObject().put("path", path) + +class DocCommentsPermissionTest { + + @Test + fun `auto-approves a plain-text markdown code target unconditionally, no override needed`() { + assertTrue(shouldAutoApproveDocComment(MUTATOR_TOOL, input("notes/plan.md"), null, SERVER_ID)) + // Every recognized mode, AND a completely unrecognized/missing one — + // this branch never even reads permissionMode. + for (mode in listOf(null, "default", "plan", "acceptEdits", "bypassPermissions", "dontAsk", "auto", "some-future-mode")) { + assertTrue(shouldAutoApproveDocComment(MUTATOR_TOOL, input("notes/plan.md"), mode, SERVER_ID)) + } + } + + @Test + fun `never auto-approves a Word or Excel target without a frictionless permission mode`() { + assertFalse(shouldAutoApproveDocComment(MUTATOR_TOOL, input("report.docx"), null, SERVER_ID)) + assertFalse(shouldAutoApproveDocComment(MUTATOR_TOOL, input("report.docx"), "default", SERVER_ID)) + assertFalse(shouldAutoApproveDocComment(MUTATOR_TOOL, input("sheet.xlsx"), "plan", SERVER_ID)) + } + + @Test + fun `auto-approves a Word or Excel target in acceptEdits or bypassPermissions mode`() { + assertTrue(shouldAutoApproveDocComment(MUTATOR_TOOL, input("report.docx"), "acceptEdits", SERVER_ID)) + assertTrue(shouldAutoApproveDocComment(MUTATOR_TOOL, input("sheet.xlsx"), "bypassPermissions", SERVER_ID)) + } + + @Test + fun `never auto-approves a Word or Excel target for dontAsk or auto — neither is a documented unconditional accept`() { + assertFalse(shouldAutoApproveDocComment(MUTATOR_TOOL, input("report.docx"), "dontAsk", SERVER_ID)) + assertFalse(shouldAutoApproveDocComment(MUTATOR_TOOL, input("report.docx"), "auto", SERVER_ID)) + } + + @Test + fun `never auto-approves a Word or Excel target for an unrecognized mode string`() { + assertFalse(shouldAutoApproveDocComment(MUTATOR_TOOL, input("report.docx"), "some-future-mode", SERVER_ID)) + } + + @Test + fun `ignores a tool name it does not recognize, including the read-only tool`() { + assertFalse(shouldAutoApproveDocComment("Bash", input("notes/plan.md"), null, SERVER_ID)) + assertFalse(shouldAutoApproveDocComment(READ_TOOL, input("notes/plan.md"), null, SERVER_ID)) + } + + @Test + fun `never approves when path is missing or not a string`() { + assertFalse(shouldAutoApproveDocComment(MUTATOR_TOOL, JSONObject(), null, SERVER_ID)) + assertFalse(shouldAutoApproveDocComment(MUTATOR_TOOL, JSONObject().put("path", 42), null, SERVER_ID)) + } + + @Test + fun `a tool name composed under a DIFFERENT session's server id never matches`() { + val otherServerId = "youcoded-doc-comments-c0ffee00" + val toolUnderOther = DocCommentsMcpNames.toolName(otherServerId, "ReplyToComment") + assertFalse(shouldAutoApproveDocComment(toolUnderOther, input("notes/plan.md"), null, SERVER_ID)) + } + + @Test + fun `fails closed when no server id is known for this session at all`() { + assertFalse(shouldAutoApproveDocComment(MUTATOR_TOOL, input("notes/plan.md"), null, null)) + } + + @Test + fun `an absolute txt path that is a symlink to a real docx is never auto-approved, even unconditionally`() { + val dir = Files.createTempDirectory("ycd-doccomments-permission-").toFile().apply { deleteOnExit() } + val realDocx = java.io.File(dir, "real.docx").apply { writeText("pk-stub") } + val disguise = java.io.File(dir, "looks-like-notes.txt") + Files.createSymbolicLink(disguise.toPath(), realDocx.toPath()) + // Would otherwise hit the unconditional "plain text" branch, since + // nativeFormatFor(".txt") is null — the fail-safe must intercept + // BEFORE that check. + assertFalse(shouldAutoApproveDocComment(MUTATOR_TOOL, input(disguise.absolutePath), null, SERVER_ID)) + // acceptEdits doesn't rescue it either — the symlink check is + // unconditional, not mode-gated. + assertFalse(shouldAutoApproveDocComment(MUTATOR_TOOL, input(disguise.absolutePath), "acceptEdits", SERVER_ID)) + } + + @Test + fun `an ordinary absolute plain-text path that is NOT a symlink is unaffected`() { + val dir = Files.createTempDirectory("ycd-doccomments-permission-plain-").toFile().apply { deleteOnExit() } + val plain = java.io.File(dir, "notes.md").apply { writeText("hello") } + assertTrue(shouldAutoApproveDocComment(MUTATOR_TOOL, input(plain.absolutePath), null, SERVER_ID)) + } + + @Test + fun `a relative txt path is never treated as a symlink here — no cwd to resolve it against`() { + // Documented, pre-existing limitation mirrored from desktop's own + // isPathItselfASymlink (isAbsolute short-circuit) — a relative path + // still gets the ordinary plain-text unconditional approval. + assertTrue(shouldAutoApproveDocComment(MUTATOR_TOOL, input("looks-like-notes.txt"), null, SERVER_ID)) + } + + @Test + fun `a realistic full PermissionRequest-shaped payload is read the same way`() { + val payload = JSONObject() + .put("tool_name", MUTATOR_TOOL) + .put("tool_input", JSONObject().put("path", "report.docx").put("commentId", "w-1").put("text", "hi")) + .put("permission_mode", "acceptEdits") + val mode = if (payload.has("permission_mode")) payload.getString("permission_mode") else null + assertTrue( + shouldAutoApproveDocComment( + payload.getString("tool_name"), + payload.getJSONObject("tool_input"), + mode, + SERVER_ID, + ), + ) + } +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsStoreTest.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsStoreTest.kt new file mode 100644 index 000000000..78180e327 --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/DocCommentsStoreTest.kt @@ -0,0 +1,739 @@ +// Pins T4 of the doc-comments build (docs/active/specs/2026-09-26-doc- +// comments-build-design.md §1.5, §1.6, §9.1 point 3): the plain-text +// PersistedComment JSON sidecar store on Android — an in-process Mutex fast +// path in front of the REAL cross-process mkdir lock +// (`com.youcoded.app.artifacts.mutateFileUnderLock`, F1, T4 implementation +// review), the SAME lock protocol desktop's `cas-write.ts` uses, so this +// store, desktop's main process, and the Claude Code MCP script's own +// dependency-free reimplementation (design §9.1/§9.2) all exclude each other +// over the same sidecar — see DocCommentsStore.kt's own header for the full +// correction (this file's ORIGINAL header claimed Android needs no +// cross-process lock at all; that was wrong). +// +// Fixture: app/src/test/resources/doc-comments/json-sidecar/thread.json — +// copied VERBATIM from desktop/tests/fixtures/doc-comments/json-sidecar/ +// thread.json, so a fixture drift here can never silently make the +// cross-platform parity claim (T4's own pinning-test row: "shared JSON +// fixture both platforms round-trip") compare two different inputs. The +// desktop half of this same parity claim is +// desktop/tests/doc-comments-json-sidecar-fixture-parity.test.ts. +package com.youcoded.app.doccomments + +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +private fun tempProjectRoot(): File = Files.createTempDirectory("ycd-doccomments-store-").toFile().apply { deleteOnExit() } + +private fun fixtureText(name: String): String { + val stream = object {}.javaClass.getResourceAsStream("/doc-comments/json-sidecar/$name") + ?: fail("missing test resource doc-comments/json-sidecar/$name") + return stream.use { it.readBytes().toString(Charsets.UTF_8) } +} + +private val TEXT_SELECTOR = CommentSelector.Text(TextQuoteSelector(exact = "hello", prefix = "", suffix = " world", occurrence = 0)) + +class DocCommentsStoreTest { + + // ── listComments — missing-file default ───────────────────────────── + @Test + fun `a file with no sidecar returns an empty list, never an error`() = runTest { + val root = tempProjectRoot() + val result = listComments("docs/plan.md", root.path, root) + assertEquals(StoreResult.Ok(emptyList()), result) + } + + // ── read-modify-write round trip ───────────────────────────────────── + @Test + fun `add then reply then resolve then reopen round-trips through the sidecar on disk`() = runTest { + val root = tempProjectRoot() + val added = addComment( + "docs/plan.md", root.path, TEXT_SELECTOR, "Can we cut this?", "user", root, + ) + assertTrue(added is StoreResult.Ok, "expected Ok, got $added") + val id = (added as StoreResult.Ok).value + + val sidecarFile = File(root, ".youcoded/comments/docs/plan.md.json") + assertTrue(sidecarFile.exists()) + val onDisk1 = CommentsSidecarFile.parse(sidecarFile.readText())!! + assertEquals(1, onDisk1.comments.size) + assertEquals(id, onDisk1.comments[0].id) + assertEquals(false, onDisk1.comments[0].resolved) + assertTrue(onDisk1.comments[0].history.isEmpty()) + + // T5 review parity (design §1.6, F2): `reply` now returns the real + // persisted `CommentReply`, mirroring desktop's own enrichment. + val replied = replyToComment("docs/plan.md", root.path, id, "Yes", "assistant", root) + assertTrue(replied is StoreResult.Ok, "expected Ok, got $replied") + assertEquals(CommentReply("$id-r1", "assistant", "Yes", (replied as StoreResult.Ok).value.createdAt), replied.value) + + val resolved = resolveComment("docs/plan.md", root.path, id, "assistant", root) + assertEquals(StoreResult.Ok(Unit), resolved) + + val reopened = reopenComment("docs/plan.md", root.path, id, "user", root) + assertEquals(StoreResult.Ok(Unit), reopened) + + val final = CommentsSidecarFile.parse(sidecarFile.readText())!! + assertEquals(1, final.comments.size) + val comment = final.comments[0] + assertEquals(1, comment.replies.size) + assertEquals("assistant", comment.replies[0].author) + assertEquals("Yes", comment.replies[0].text) + assertEquals("$id-r1", comment.replies[0].id) + // Full resolve/reopen AUDIT TRAIL, not just the latest state (§1.1). + assertEquals(2, comment.history.size) + assertEquals("resolved", comment.history[0].action) + assertEquals("reopened", comment.history[1].action) + assertEquals(false, comment.resolved) + } + + @Test + fun `moveComment replaces the selector wholesale without touching text or replies`() = runTest { + val root = tempProjectRoot() + val added = addComment("docs/plan.md", root.path, TEXT_SELECTOR, "text", "user", root) + val id = (added as StoreResult.Ok).value + val newSelector = CommentSelector.Cell(CellSelector("B2", "Sheet1")) + val moved = moveComment("docs/plan.md", root.path, id, newSelector, root) + assertEquals(StoreResult.Ok(Unit), moved) + + val listed = listComments("docs/plan.md", root.path, root) + assertTrue(listed is StoreResult.Ok) + val comment = (listed as StoreResult.Ok).value.single() + assertEquals("text", comment.text) + assertTrue(comment.selector is CommentSelector.Cell) + assertEquals("B2", (comment.selector as CommentSelector.Cell).selector.cell) + } + + // ── the "cold-start" case — the same shape T3's own pinning test uses ── + @Test + fun `reply resolve reopen and move work against a comment id never previously listed in this process`() = runTest { + val root = tempProjectRoot() + val added = addComment("docs/report.md", root.path, TEXT_SELECTOR, "root comment", "user", root) + val id = (added as StoreResult.Ok).value + + // A FRESH call sequence with no prior listComments() in between — + // proves path-based resolution works cold, the exact case that most + // concretely breaks without the required `path` field on these four + // channels (design review 3, F1; T4's own pinning-test row). + // T5 review parity (design §1.6, F2): `reply` returns the persisted + // `CommentReply` here too, cold-start included. + val cold = replyToComment("docs/report.md", root.path, id, "cold reply", "assistant", root) + assertTrue(cold is StoreResult.Ok, "expected Ok, got $cold") + assertEquals(CommentReply("$id-r1", "assistant", "cold reply", (cold as StoreResult.Ok).value.createdAt), cold.value) + assertEquals(StoreResult.Ok(Unit), resolveComment("docs/report.md", root.path, id, "user", root)) + assertEquals(StoreResult.Ok(Unit), reopenComment("docs/report.md", root.path, id, "user", root)) + assertEquals(StoreResult.Ok(Unit), moveComment("docs/report.md", root.path, id, TEXT_SELECTOR, root)) + } + + @Test + fun `mutating an unknown comment id refuses comment-not-found, never creates one`() = runTest { + val root = tempProjectRoot() + val result = replyToComment("docs/plan.md", root.path, "c-does-not-exist", "hi", "user", root) + assertEquals(StoreResult.Err(DocCommentsError.COMMENT_NOT_FOUND), result) + } + + // ── containment refusal ─────────────────────────────────────────────── + @Test + fun `a traversal-shaped path is refused, never escapes the project`() = runTest { + val root = tempProjectRoot() + val result = addComment("../../../../etc/passwd", root.path, TEXT_SELECTOR, "x", "user", root) + assertEquals(StoreResult.Err(DocCommentsError.PATH_OUTSIDE_PROJECT), result) + } + + @Test + fun `an absolute path outside the project is refused`() = runTest { + val root = tempProjectRoot() + val outside = Files.createTempFile("ycd-doccomments-outside-", ".md").toFile() + outside.deleteOnExit() + val result = addComment(outside.absolutePath, root.path, TEXT_SELECTOR, "x", "user", root) + assertEquals(StoreResult.Err(DocCommentsError.PATH_OUTSIDE_PROJECT), result) + } + + @Test + fun `a symlink inside the project pointing outside it is refused, not trusted by its own name`() = runTest { + val root = tempProjectRoot() + val outsideSecret = Files.createTempFile("ycd-doccomments-secret-", ".md").toFile() + outsideSecret.deleteOnExit() + val linkPath = File(root, "notes.md").toPath() + try { + Files.createSymbolicLink(linkPath, outsideSecret.toPath()) + } catch (_: Exception) { + return@runTest // no symlink rights on this platform — skip, matches TS test's own escape hatch + } + val result = addComment("notes.md", root.path, TEXT_SELECTOR, "x", "user", root) + assertEquals(StoreResult.Err(DocCommentsError.PATH_OUTSIDE_PROJECT), result) + } + + @Test + fun `a symlinked project root still resolves comments inside it correctly`() = runTest { + val root = tempProjectRoot() + val alias = File(root.parentFile, "ycd-doccomments-alias-${System.nanoTime()}") + try { + Files.createSymbolicLink(alias.toPath(), root.toPath()) + } catch (_: Exception) { + return@runTest + } + try { + val result = addComment("docs/via-symlink.md", alias.path, TEXT_SELECTOR, "via-symlink", "user", root) + assertTrue(result is StoreResult.Ok, "expected Ok, got $result") + val realRoot = root.canonicalFile + val sidecar = File(realRoot, ".youcoded/comments/docs/via-symlink.md.json") + assertTrue(sidecar.exists()) + } finally { + alias.delete() + } + } + + @Test + fun `walk-up depth cap refuses a pathologically deep non-existent ancestor chain instead of hanging`() = runTest { + val root = tempProjectRoot() + val deepRel = (0 until 250).joinToString("/") { "level$it" } + "/file.md" + val result = addComment(deepRel, root.path, TEXT_SELECTOR, "x", "user", root) + assertEquals(StoreResult.Err(DocCommentsError.PATH_OUTSIDE_PROJECT), result) + } + + // ── fallback path (no known project root) ──────────────────────────── + @Test + fun `a relative path with no projectRoot is refused as not-absolute`() = runTest { + val home = tempProjectRoot() + val result = addComment("relative/file.md", null, TEXT_SELECTOR, "x", "user", home) + assertEquals(StoreResult.Err(DocCommentsError.PATH_NOT_ABSOLUTE), result) + } + + @Test + fun `a standalone file with no project root lands in the per-machine fallback store`() = runTest { + val home = tempProjectRoot() + val looseFile = Files.createTempFile("ycd-doccomments-loose-", ".md").toFile() + looseFile.deleteOnExit() + val added = addComment(looseFile.absolutePath, null, TEXT_SELECTOR, "loose", "user", home) + assertTrue(added is StoreResult.Ok, "expected Ok, got $added") + + val listed = listComments(looseFile.absolutePath, null, home) + assertTrue(listed is StoreResult.Ok) + assertEquals(1, (listed as StoreResult.Ok).value.size) + assertEquals("loose", listed.value[0].text) + + // Lands under ~/.youcoded/loose-file-comments/.json, never + // inside the caller's own directory (§1.4). + val fallbackDir = File(home, ".youcoded/loose-file-comments") + assertTrue(fallbackDir.isDirectory) + assertEquals(1, fallbackDir.listFiles()?.size ?: 0) + } + + // ── concurrency — a true race, not just a sequential check ─────────── + @Test + fun `two writers racing to create the SAME sidecar for the first time both land`() = runTest { + val root = tempProjectRoot() + val jobs = listOf("first", "second").map { text -> + async { + addComment("docs/race.md", root.path, TEXT_SELECTOR, text, "user", root) + } + } + val results = jobs.map { it.await() } + assertTrue(results.all { it is StoreResult.Ok }, "expected both Ok, got $results") + + val sidecar = File(root, ".youcoded/comments/docs/race.md.json") + val onDisk = CommentsSidecarFile.parse(sidecar.readText())!! + assertEquals(2, onDisk.comments.size) + assertEquals(setOf("first", "second"), onDisk.comments.map { it.text }.toSet()) + } + + // ── shared JSON fixture round trip (T4's own pinning-test row) ──────── + @Test + fun `Kotlin's own reader parses the checked-in fixture the same way desktop's store produced it`() { + val json = fixtureText("thread.json") + val parsed = CommentsSidecarFile.parse(json) + assertNotNull(parsed, "fixture failed to parse") + assertEquals(1, parsed.comments.size) + val comment = parsed.comments[0] + assertEquals("c-fixture-0001", comment.id) + assertEquals("docs/plan.md", comment.path) + assertEquals("Can we cut this?", comment.text) + assertEquals("person:Priya Shah", comment.author) + assertEquals(true, comment.resolved) + assertTrue(comment.selector is CommentSelector.Text) + val sel = (comment.selector as CommentSelector.Text).selector + assertEquals("cut the onboarding step", sel.exact) + assertEquals("we should probably ", sel.prefix) + assertEquals(" before shipping", sel.suffix) + assertEquals(1, comment.replies.size) + assertEquals("Agreed, cutting it.", comment.replies[0].text) + assertEquals(1, comment.history.size) + assertEquals("resolved", comment.history[0].action) + } + + @Test + fun `a reply Kotlin appends to the fixture keeps the exact field shape desktop's own reader expects`() = runTest { + val root = tempProjectRoot() + val sidecar = File(root, ".youcoded/comments/docs/plan.md.json") + sidecar.parentFile?.mkdirs() + sidecar.writeText(fixtureText("thread.json")) + + // T5 review parity (design §1.6, F2): `reply` returns the real + // persisted `CommentReply` — the fixture already has one reply, so + // this new one lands as ordinal 2. + val replied = replyToComment("docs/plan.md", root.path, "c-fixture-0001", "from android", "assistant", root) + assertTrue(replied is StoreResult.Ok, "expected Ok, got $replied") + assertEquals( + CommentReply("c-fixture-0001-r2", "assistant", "from android", (replied as StoreResult.Ok).value.createdAt), + replied.value, + ) + + val onDiskRaw = JSONObject(sidecar.readText()) + assertEquals(1, onDiskRaw.getInt("version")) + val comment = onDiskRaw.getJSONArray("comments").getJSONObject(0) + // Field names/types a TS `JSON.parse` of this same file would see — + // the desktop half of this parity claim + // (doc-comments-json-sidecar-fixture-parity.test.ts) asserts the + // mirror image: a reply DESKTOP appends keeps this same shape. + for (key in listOf("id", "path", "selector", "text", "author", "createdAt", "replies", "resolved", "history")) { + assertTrue(comment.has(key), "missing key: $key") + } + assertTrue(comment.get("createdAt") is Number) + assertTrue(comment.get("resolved") is Boolean) + val replies = comment.getJSONArray("replies") + assertEquals(2, replies.length()) + val newReply = replies.getJSONObject(1) + assertEquals("assistant", newReply.getString("author")) + assertEquals("from android", newReply.getString("text")) + assertEquals("c-fixture-0001-r2", newReply.getString("id")) + } + + // Edit/delete build (2026-09-28, design doc §"Edit and delete") — this + // task's own T21 parity extension, previously "not extended in this + // pass". Same shared checked-in fixture, same "the field shape + // desktop's own reader/writer expects" claim the reply test above + // already makes; the TS mirror is doc-comments-json-sidecar-fixture- + // parity.test.ts's own edit/delete tests. + @Test + fun `editComment on the fixture keeps the exact field shape desktop's own reader expects`() = runTest { + val root = tempProjectRoot() + val sidecar = File(root, ".youcoded/comments/docs/plan.md.json") + sidecar.parentFile?.mkdirs() + sidecar.writeText(fixtureText("thread.json")) + + val edited = editComment("docs/plan.md", root.path, "c-fixture-0001", "Edited from Android.", root) + assertTrue(edited is StoreResult.Ok, "expected Ok, got $edited") + + val onDiskRaw = JSONObject(sidecar.readText()) + val comment = onDiskRaw.getJSONArray("comments").getJSONObject(0) + assertEquals("Edited from Android.", comment.getString("text")) + // No "edited" marker is ever stored — every other field, including + // the reply, stays exactly as the fixture had it. + assertEquals("c-fixture-0001", comment.getString("id")) + assertEquals(true, comment.getBoolean("resolved")) + assertEquals(1, comment.getJSONArray("replies").length()) + } + + @Test + fun `editReply on the fixture keeps the exact field shape desktop's own reader expects`() = runTest { + val root = tempProjectRoot() + val sidecar = File(root, ".youcoded/comments/docs/plan.md.json") + sidecar.parentFile?.mkdirs() + sidecar.writeText(fixtureText("thread.json")) + + val edited = editReply("docs/plan.md", root.path, "c-fixture-0001", "c-fixture-0001-r1", "Edited reply from Android.", root) + assertTrue(edited is StoreResult.Ok, "expected Ok, got $edited") + assertEquals( + CommentReply("c-fixture-0001-r1", "user", "Edited reply from Android.", 1758000100000L), + (edited as StoreResult.Ok).value, + ) + + val onDiskRaw = JSONObject(sidecar.readText()) + val comment = onDiskRaw.getJSONArray("comments").getJSONObject(0) + assertEquals("Can we cut this?", comment.getString("text")) // root untouched + val replies = comment.getJSONArray("replies") + assertEquals(1, replies.length()) + assertEquals("Edited reply from Android.", replies.getJSONObject(0).getString("text")) + } + + @Test + fun `deleteReply on the fixture removes just the one reply, leaving the root comment's own field shape untouched`() = runTest { + val root = tempProjectRoot() + val sidecar = File(root, ".youcoded/comments/docs/plan.md.json") + sidecar.parentFile?.mkdirs() + sidecar.writeText(fixtureText("thread.json")) + + val deleted = deleteReply("docs/plan.md", root.path, "c-fixture-0001", "c-fixture-0001-r1", root) + assertTrue(deleted is StoreResult.Ok, "expected Ok, got $deleted") + + val onDiskRaw = JSONObject(sidecar.readText()) + val comments = onDiskRaw.getJSONArray("comments") + assertEquals(1, comments.length()) + val comment = comments.getJSONObject(0) + assertEquals("c-fixture-0001", comment.getString("id")) + assertEquals("Can we cut this?", comment.getString("text")) + assertEquals(true, comment.getBoolean("resolved")) + assertEquals(0, comment.getJSONArray("replies").length()) + } + + @Test + fun `deleteComment on the fixture removes the WHOLE thread -- root and reply both gone`() = runTest { + val root = tempProjectRoot() + val sidecar = File(root, ".youcoded/comments/docs/plan.md.json") + sidecar.parentFile?.mkdirs() + sidecar.writeText(fixtureText("thread.json")) + + val deleted = deleteComment("docs/plan.md", root.path, "c-fixture-0001", root) + assertTrue(deleted is StoreResult.Ok, "expected Ok, got $deleted") + + val onDiskRaw = JSONObject(sidecar.readText()) + assertEquals(1, onDiskRaw.getInt("version")) + assertEquals(0, onDiskRaw.getJSONArray("comments").length()) + + val listed = listComments("docs/plan.md", root.path, root) + assertTrue(listed is StoreResult.Ok, "expected Ok, got $listed") + assertEquals(0, (listed as StoreResult.Ok).value.size) + } + + // ── F2 (T4 implementation review, major) — unknown fields survive ────── + @Test + fun `an Android reply and resolve preserve unknown top-level comment reply and history fields byte-equivalently`() = runTest { + val root = tempProjectRoot() + val sidecar = File(root, ".youcoded/comments/docs/plan.md.json") + sidecar.parentFile?.mkdirs() + // A sidecar carrying a field this Kotlin build doesn't know about at + // every level the design calls out: the FILE itself, a COMMENT, and + // one of its REPLIES — standing in for a newer schema field, or one + // written by desktop/the MCP script (the other two of the three-way + // JSON sidecar story, design §9.1). + val onDiskBefore = JSONObject() + .put("version", 1) + .put("syncedFromDevice", "pixel-9a") // unknown TOP-LEVEL field + .put( + "comments", + JSONArray().put( + JSONObject() + .put("id", "c-unknown-0001") + .put("path", "docs/plan.md") + .put("selector", TEXT_SELECTOR.toJson()) + .put("text", "Can we cut this?") + .put("author", "user") + .put("createdAt", 1758000000000L) + .put("priority", "high") // unknown COMMENT field + .put( + "replies", + JSONArray().put( + JSONObject() + .put("id", "c-unknown-0001-r1") + .put("author", "assistant") + .put("text", "Agreed") + .put("createdAt", 1758000100000L) + .put("reactedWith", "👍"), // unknown REPLY field + ), + ) + .put("resolved", false) + .put("history", JSONArray()), + ), + ) + sidecar.writeText(onDiskBefore.toString()) + + // T5 review parity (design §1.6, F2): `reply` returns the persisted + // `CommentReply` here too. + val repliedToUnknown = replyToComment("docs/plan.md", root.path, "c-unknown-0001", "from android", "assistant", root) + assertTrue(repliedToUnknown is StoreResult.Ok, "expected Ok, got $repliedToUnknown") + assertEquals( + CommentReply("c-unknown-0001-r2", "assistant", "from android", (repliedToUnknown as StoreResult.Ok).value.createdAt), + repliedToUnknown.value, + ) + assertEquals( + StoreResult.Ok(Unit), + resolveComment("docs/plan.md", root.path, "c-unknown-0001", "user", root), + ) + + val onDiskAfter = JSONObject(sidecar.readText()) + // Top-level unknown field survived reply's/resolve's overlay. + assertEquals("pixel-9a", onDiskAfter.getString("syncedFromDevice")) + val mutatedComment = onDiskAfter.getJSONArray("comments").getJSONObject(0) + // Comment-level unknown field survived, even after two mutations. + assertEquals("high", mutatedComment.getString("priority")) + val mutatedReplies = mutatedComment.getJSONArray("replies") + assertEquals(2, mutatedReplies.length()) + // The ORIGINAL reply's own unknown field survived being carried + // through two more mutations on a DIFFERENT part of the record. + assertEquals("👍", mutatedReplies.getJSONObject(0).getString("reactedWith")) + assertEquals("from android", mutatedReplies.getJSONObject(1).getString("text")) + assertEquals(1, mutatedComment.getJSONArray("history").length()) + assertEquals("resolved", mutatedComment.getJSONArray("history").getJSONObject(0).getString("action")) + + // And Kotlin's own reader still parses the result back out correctly + // — the unknown fields are extra, not corrupting. + val reparsed = CommentsSidecarFile.parse(onDiskAfter.toString()) + assertNotNull(reparsed) + assertEquals(1, reparsed.comments.size) + assertEquals(true, reparsed.comments[0].resolved) + } + + // ── F1 (T4 implementation review, blocker) — a real second process ───── + @Test + fun `a real second OS process holding the sidecar lock blocks the store writer until it releases`() = runTest { + val root = tempProjectRoot() + val sidecarPath = File(root, ".youcoded/comments/docs/plan.md.json") + sidecarPath.parentFile?.mkdirs() + val lockDir = File(sidecarPath.path + ".lock") + val releasedMarker = File(root, "released.marker") + // A plain `sh` child — no JVM, standing in for the Claude Code MCP + // script's own dependency-free reimplementation of this SAME + // mkdir-lock algorithm (design §9.1 point 2) — mkdir's the SAME lock + // directory DocCommentsStore's cross-process lock uses, holds it for + // a while, drops a marker right before releasing it, then releases. + val proc = ProcessBuilder( + "sh", "-c", + "mkdir -p '${lockDir.parentFile!!.path}' && mkdir '${lockDir.path}' && sleep 0.4 " + + "&& touch '${releasedMarker.path}' && rmdir '${lockDir.path}'", + ).start() + val waitStart = System.currentTimeMillis() + while (!lockDir.isDirectory) { + assertTrue(System.currentTimeMillis() - waitStart < 2000, "child process never created the lock dir") + Thread.sleep(5) + } + + val result = addComment("docs/plan.md", root.path, TEXT_SELECTOR, "after the foreign lock releases", "user", root) + proc.waitFor() + + assertTrue(result is StoreResult.Ok, "expected Ok, got $result") + // The real assertion: the store's writer did not proceed until the + // foreign process had ALREADY dropped the marker on its way to + // releasing the lock — waiting on the actual signal, never a fixed + // sleep or a wall-clock-duration guess. + assertTrue(releasedMarker.exists(), "the store's writer returned before the foreign process released its lock") + } + + // ── F4 (T5 implementation review) — caller-supplied id ────────────────── + @Test + fun `uses the caller-supplied id instead of minting its own`() = runTest { + val root = tempProjectRoot() + val callerId = "c-11111111-2222-4333-8444-555555555555" + val added = addComment("docs/id.md", root.path, TEXT_SELECTOR, "x", "user", root, callerId) + assertEquals(StoreResult.Ok(callerId), added) + val sidecarFile = File(root, ".youcoded/comments/docs/id.md.json") + val onDisk = CommentsSidecarFile.parse(sidecarFile.readText())!! + assertEquals(callerId, onDisk.comments[0].id) + } + + @Test + fun `still mints its own id when none is supplied (an unupdated caller)`() = runTest { + val root = tempProjectRoot() + val added = addComment("docs/id2.md", root.path, TEXT_SELECTOR, "x", "user", root) + assertTrue(added is StoreResult.Ok, "expected Ok, got $added") + val id = (added as StoreResult.Ok).value + assertTrue(Regex("^c-[0-9a-f-]{36}$", RegexOption.IGNORE_CASE).matches(id), "id '$id' is not shaped like a real comment id") + } + + @Test + fun `refuses a caller-supplied id that is not shaped like this store's own ids`() = runTest { + val root = tempProjectRoot() + val result = addComment("docs/id3.md", root.path, TEXT_SELECTOR, "x", "user", root, "not-a-real-id") + assertEquals(StoreResult.Err(DocCommentsError.INVALID_ID), result) + } + + @Test + fun `refuses a caller-supplied id that collides with one already in this file's sidecar`() = runTest { + val root = tempProjectRoot() + val callerId = "c-11111111-2222-4333-8444-555555555555" + val first = addComment("docs/id4.md", root.path, TEXT_SELECTOR, "first", "user", root, callerId) + assertTrue(first is StoreResult.Ok, "expected Ok, got $first") + val second = addComment("docs/id4.md", root.path, TEXT_SELECTOR, "second", "user", root, callerId) + assertEquals(StoreResult.Err(DocCommentsError.DUPLICATE_ID), second) + val sidecarFile = File(root, ".youcoded/comments/docs/id4.md.json") + val onDisk = CommentsSidecarFile.parse(sidecarFile.readText())!! + assertEquals(1, onDisk.comments.size) + assertEquals("first", onDisk.comments[0].text) + } + + // ── mutateSidecar's IOException mapping (code review 2026-09-27, F3) ── + // Before this fix, EVERY java.io.IOException escaping mutateFileUnderLock + // — from the READ side (an unreadable existing sidecar) or the WRITE side + // (a failed atomic rename) — was reported as SIDECAR_CORRUPT. These two + // tests pin that the two are now told apart. + + @Test + fun `malformed JSON content in an existing sidecar still refuses as SIDECAR_CORRUPT`() = runTest { + val root = tempProjectRoot() + // A real, readable, regular file — just not valid sidecar JSON. + // `CommentsSidecarFile.parse` returning null (not an exception) is + // the common real-world "sidecar corrupt" shape (a torn write, hand + // edited by mistake); this pins it's still SIDECAR_CORRUPT and was + // NEVER routed through the IOException catch this review's F3 + // finding is about — it must stay unaffected by that fix. + val sidecarFile = File(root, ".youcoded/comments/docs/plan.md.json") + sidecarFile.parentFile!!.mkdirs() + sidecarFile.writeText("{ not valid json at all") + + val result = addComment("docs/plan.md", root.path, TEXT_SELECTOR, "x", "user", root) + assertEquals(StoreResult.Err(DocCommentsError.SIDECAR_CORRUPT), result) + } + + @Test + fun `a write-time failure (not a read failure) refuses as SIDECAR_WRITE_FAILED, never SIDECAR_CORRUPT`() = runTest { + val root = tempProjectRoot() + // A real, valid, READABLE sidecar first — the read (and `apply`) + // succeed; only the SUBSEQUENT write must fail. + val added = addComment("docs/plan.md", root.path, TEXT_SELECTOR, "first", "user", root) + assertTrue(added is StoreResult.Ok, "expected Ok, got $added") + + // `mutateFileUnderLock` (CasWrite.kt) writes its new content to + // ".tmp" before the atomic rename onto `target` — a + // DIRECTORY already sitting at exactly that ".tmp" path makes that + // specific write step fail (`writeText` onto a directory throws), + // strictly AFTER the read and `apply` have already succeeded. This + // is more precise than a permission-bit trick: this codebase's own + // mkdir-based lock (`acquireLock`) ALSO creates something inside the + // SAME parent directory the tmp/rename step writes into, so making + // that whole directory non-writable fails lock ACQUISITION instead + // (a different, pre-existing failure this fix doesn't change) — + // confirmed empirically while writing this test. + val sidecarFile = File(root, ".youcoded/comments/docs/plan.md.json") + val tmpPath = File(sidecarFile.parentFile, "${sidecarFile.name}.tmp") + assertTrue(tmpPath.mkdirs(), "test setup: could not pre-create the .tmp collision directory") + + val result = replyToComment( + "docs/plan.md", root.path, (added as StoreResult.Ok).value, "a reply", "user", root, + ) + assertEquals(StoreResult.Err(DocCommentsError.SIDECAR_WRITE_FAILED), result) + } + + // ── Edit/delete build (2026-09-28, design doc §"Edit and delete") ─────── + // Mirrors desktop's own doc-comments-store.test.ts `describe('edit / + // delete', ...)` block. + + @Test + fun `editComment overwrites text and leaves everything else unchanged, no history entry`() = runTest { + val root = tempProjectRoot() + val added = addComment("docs/plan.md", root.path, TEXT_SELECTOR, "original text", "user", root) + assertTrue(added is StoreResult.Ok, "expected Ok, got $added") + val id = (added as StoreResult.Ok).value + + val edited = editComment("docs/plan.md", root.path, id, "edited text", root) + assertEquals(StoreResult.Ok(Unit), edited) + + val listed = listComments("docs/plan.md", root.path, root) + assertTrue(listed is StoreResult.Ok) + val comment = (listed as StoreResult.Ok).value.single() + assertEquals(id, comment.id) + assertEquals("edited text", comment.text) + assertEquals("user", comment.author) + assertEquals(TEXT_SELECTOR, comment.selector) + // No "edited" marker is ever stored (decisions.json) — a plain text + // overwrite, never a history-append the way resolve/reopen works. + assertTrue(comment.history.isEmpty()) + } + + @Test + fun `editComment on an unknown id refuses comment-not-found`() = runTest { + val root = tempProjectRoot() + val result = editComment("docs/plan.md", root.path, "c-does-not-exist", "x", root) + assertEquals(StoreResult.Err(DocCommentsError.COMMENT_NOT_FOUND), result) + } + + @Test + fun `deleteComment removes the whole thread including replies, a sibling thread survives, the id refuses cleanly afterward`() = runTest { + val root = tempProjectRoot() + val added = addComment("docs/plan.md", root.path, TEXT_SELECTOR, "root", "user", root) + val id = (added as StoreResult.Ok).value + replyToComment("docs/plan.md", root.path, id, "a reply", "assistant", root) + + val siblingAdded = addComment("docs/plan.md", root.path, TEXT_SELECTOR, "sibling", "user", root) + val siblingId = (siblingAdded as StoreResult.Ok).value + + val deleted = deleteComment("docs/plan.md", root.path, id, root) + assertEquals(StoreResult.Ok(Unit), deleted) + + val listed = listComments("docs/plan.md", root.path, root) + assertTrue(listed is StoreResult.Ok) + val remaining = (listed as StoreResult.Ok).value + assertEquals(1, remaining.size) + assertEquals(siblingId, remaining[0].id) + assertEquals("sibling", remaining[0].text) + + assertEquals(StoreResult.Err(DocCommentsError.COMMENT_NOT_FOUND), editComment("docs/plan.md", root.path, id, "x", root)) + } + + @Test + fun `deleteComment on an unknown id refuses comment-not-found`() = runTest { + val root = tempProjectRoot() + val result = deleteComment("docs/plan.md", root.path, "c-does-not-exist", root) + assertEquals(StoreResult.Err(DocCommentsError.COMMENT_NOT_FOUND), result) + } + + @Test + fun `editReply overwrites only the target reply's text, other replies untouched`() = runTest { + val root = tempProjectRoot() + val added = addComment("docs/plan.md", root.path, TEXT_SELECTOR, "root", "user", root) + val id = (added as StoreResult.Ok).value + val reply1 = (replyToComment("docs/plan.md", root.path, id, "first reply", "assistant", root) as StoreResult.Ok).value + val reply2 = (replyToComment("docs/plan.md", root.path, id, "second reply", "user", root) as StoreResult.Ok).value + + val edited = editReply("docs/plan.md", root.path, id, reply1.id, "edited first reply", root) + assertTrue(edited is StoreResult.Ok, "expected Ok, got $edited") + assertEquals(CommentReply(reply1.id, "assistant", "edited first reply", reply1.createdAt), (edited as StoreResult.Ok).value) + + val listed = (listComments("docs/plan.md", root.path, root) as StoreResult.Ok).value.single() + assertEquals(2, listed.replies.size) + assertEquals("edited first reply", listed.replies[0].text) + assertEquals(reply2.id, listed.replies[1].id) + assertEquals("second reply", listed.replies[1].text) + } + + @Test + fun `editReply on an unknown reply id refuses comment-not-found`() = runTest { + val root = tempProjectRoot() + val added = addComment("docs/plan.md", root.path, TEXT_SELECTOR, "root", "user", root) + val id = (added as StoreResult.Ok).value + val result = editReply("docs/plan.md", root.path, id, "$id-r99", "x", root) + assertEquals(StoreResult.Err(DocCommentsError.COMMENT_NOT_FOUND), result) + } + + @Test + fun `deleteReply removes one reply — the parent and other replies survive`() = runTest { + val root = tempProjectRoot() + val added = addComment("docs/plan.md", root.path, TEXT_SELECTOR, "root", "user", root) + val id = (added as StoreResult.Ok).value + val reply1 = (replyToComment("docs/plan.md", root.path, id, "first reply", "assistant", root) as StoreResult.Ok).value + val reply2 = (replyToComment("docs/plan.md", root.path, id, "second reply", "user", root) as StoreResult.Ok).value + + val deleted = deleteReply("docs/plan.md", root.path, id, reply1.id, root) + assertEquals(StoreResult.Ok(Unit), deleted) + + val listed = (listComments("docs/plan.md", root.path, root) as StoreResult.Ok).value.single() + assertEquals(id, listed.id) + assertEquals("root", listed.text) + assertEquals(1, listed.replies.size) + assertEquals(reply2.id, listed.replies[0].id) + } + + // 2026-09-28 PR review: ids were "-r", so after a + // middle reply was deleted the next reply reused a live id. + @Test + fun `a reply added after a middle reply was deleted gets an unused id`() = runTest { + val root = tempProjectRoot() + val id = (addComment("docs/plan.md", root.path, TEXT_SELECTOR, "root", "user", root) as StoreResult.Ok).value + val replies = listOf("one", "two", "three").map { + (replyToComment("docs/plan.md", root.path, id, it, "user", root) as StoreResult.Ok).value + } + deleteReply("docs/plan.md", root.path, id, replies[1].id, root) + val fresh = (replyToComment("docs/plan.md", root.path, id, "four", "user", root) as StoreResult.Ok).value + assertTrue(fresh.id != replies[0].id && fresh.id != replies[2].id, "reused id ${fresh.id}") + + deleteReply("docs/plan.md", root.path, id, fresh.id, root) + val listed = (listComments("docs/plan.md", root.path, root) as StoreResult.Ok).value.single() + assertEquals(listOf("one", "three"), listed.replies.map { it.text }) + } + + @Test + fun `deleteReply on an unknown reply id refuses comment-not-found`() = runTest { + val root = tempProjectRoot() + val added = addComment("docs/plan.md", root.path, TEXT_SELECTOR, "root", "user", root) + val id = (added as StoreResult.Ok).value + val result = deleteReply("docs/plan.md", root.path, id, "$id-r99", root) + assertEquals(StoreResult.Err(DocCommentsError.COMMENT_NOT_FOUND), result) + } +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/DocxCommentsCrossPlatformParityTest.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/DocxCommentsCrossPlatformParityTest.kt new file mode 100644 index 000000000..c2fb92ee0 --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/DocxCommentsCrossPlatformParityTest.kt @@ -0,0 +1,241 @@ +// Cross-platform WRITE parity for T17 of the doc-comments build (docs/ +// active/specs/2026-09-26-doc-comments-build-design.md §3.3/§3.2a, §8 T17, +// and the slice of T21's own "golden-fixture parity" guard this task's own +// brief asks be covered for docx specifically). Proves the Kotlin writer and +// the REAL desktop writer (`desktop/src/main/doc-comments/docx-comments.ts`) +// agree on the SAME operation against the SAME input fixture, not just that +// each independently passes its own tests. +// +// Fixtures: app/src/test/resources/doc-comments/write-golden/*.docx — the +// desktop writer's OWN real output, generated by the committed script +// `desktop/tests/fixtures/doc-comments/generate-docx-write-golden.mjs` +// (copied verbatim into this module's test resources, mirroring T16's own +// golden/*.json convention) — never hand-transcribed. Each `.docx` has a +// matching `.json` sidecar recording the EXACT operation/arguments used, so +// this test replays the identical operation with the Kotlin writer against a +// fresh copy of the SAME original source fixture. +// +// WHY the comparison excludes `createdAt` (and nothing else) — see the +// generator script's own "WHY NOT literal id/date injection" header comment: +// `w15:paraId`/durable ids are internal, never exposed in `PersistedComment`; +// `w:id` itself (add/reply's new comment id) IS fully deterministic — +// `(max existing w:id) + 1`, scanned fresh from the SAME original fixture by +// both writers — so it's compared for real equality, not excluded. Only the +// real wall-clock creation timestamp is excluded, the same way any two- +// process timing test excludes a real clock rather than trying to freeze it +// through a fragile cross-language mechanism. +package com.youcoded.app.doccomments + +import org.json.JSONObject +import org.junit.Test +import java.io.File +import java.nio.file.Files +import java.util.zip.ZipFile +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +private fun resourceBytes(path: String): ByteArray { + val stream = object {}.javaClass.getResourceAsStream("/doc-comments/$path") ?: fail("missing test resource doc-comments/$path") + return stream.use { it.readBytes() } +} + +private fun scratchCopyOf(bytes: ByteArray, name: String): File { + val dir = Files.createTempDirectory("ycd-docx-parity-").toFile() + dir.deleteOnExit() + val target = File(dir, name) + target.writeBytes(bytes) + return target +} + +private fun scratchHome(): File { + val dir = Files.createTempDirectory("ycd-docx-parity-home-").toFile() + dir.deleteOnExit() + return dir +} + +private fun textSelector(exact: String): CommentSelector.Text = + CommentSelector.Text(TextQuoteSelector(exact = exact, prefix = "", suffix = "", occurrence = 0)) + +/** Every part name this feature's write path ever touches — everything else + * in the archive must come out byte-identical to the ORIGINAL fixture on + * both platforms, proven directly below rather than assumed. */ +private val TOUCHED_PART_NAMES = setOf( + "word/document.xml", + "word/comments.xml", + "word/commentsExtended.xml", + "[Content_Types].xml", + "word/_rels/document.xml.rels", + "word/commentsIds.xml", + "word/commentsExtensible.xml", +) + +private fun entryBytes(file: File, name: String): ByteArray? { + ZipFile(file).use { zip -> + val entry = zip.getEntry(name) ?: return null + return zip.getInputStream(entry).use { it.readBytes() } + } +} + +private fun entryNames(file: File): Set { + ZipFile(file).use { zip -> return zip.entries().asSequence().filter { !it.isDirectory }.map { it.name }.toSet() } +} + +/** Field-for-field equality EXCEPT `createdAt` on the comment itself and on + * every reply — see this file's own header for why. */ +private fun assertSameIgnoringTimestamps(expected: PersistedComment, actual: PersistedComment, label: String) { + assertEquals(expected.id, actual.id, "$label: id") + assertEquals(expected.path, actual.path, "$label: path") + assertEquals(expected.selector, actual.selector, "$label: selector") + assertEquals(expected.text, actual.text, "$label: text") + assertEquals(expected.author, actual.author, "$label: author") + assertEquals(expected.resolved, actual.resolved, "$label: resolved") + assertEquals(expected.status, actual.status, "$label: status") + assertEquals(expected.replies.size, actual.replies.size, "$label: replies.size") + for (i in expected.replies.indices) { + val e = expected.replies[i] + val a = actual.replies[i] + assertEquals(e.id, a.id, "$label: replies[$i].id") + assertEquals(e.author, a.author, "$label: replies[$i].author") + assertEquals(e.text, a.text, "$label: replies[$i].text") + } + assertEquals(expected.history.size, actual.history.size, "$label: history.size") + for (i in expected.history.indices) { + assertEquals(expected.history[i].by, actual.history[i].by, "$label: history[$i].by") + assertEquals(expected.history[i].action, actual.history[i].action, "$label: history[$i].action") + } +} + +class DocxCommentsCrossPlatformParityTest { + + private fun runCase(caseName: String) { + val json = JSONObject(String(resourceBytes("write-golden/$caseName.json"), Charsets.UTF_8)) + val fixture = json.getString("fixture") + val path = json.getString("path") + val op = json.getString("op") + val args = json.getJSONObject("args") + val desktopResult = json.getJSONObject("result") + + val originalBytes = resourceBytes(fixture) + val kotlinTarget = scratchCopyOf(originalBytes, fixture) + val home = scratchHome() + + val actualIdOrUnit: Any = when (op) { + "add" -> { + val r = kotlinx.coroutines.runBlocking { + addDocxComment(kotlinTarget.absolutePath, path, textSelector(args.getString("exact")), args.getString("text"), args.getString("author"), home) + } + assertTrue(r is DocxWriteResult.Ok, "$caseName: Kotlin add failed: $r") + (r as DocxWriteResult.Ok).value + } + "reply" -> { + val r = kotlinx.coroutines.runBlocking { + replyToDocxComment(kotlinTarget.absolutePath, path, args.getString("id"), args.getString("text"), args.getString("author"), home) + } + assertTrue(r is DocxWriteResult.Ok, "$caseName: Kotlin reply failed: $r") + Unit + } + "resolve" -> { + val r = kotlinx.coroutines.runBlocking { resolveDocxComment(kotlinTarget.absolutePath, path, args.getString("id"), home) } + assertTrue(r is DocxWriteResult.Ok, "$caseName: Kotlin resolve failed: $r") + Unit + } + "reopen" -> { + val r = kotlinx.coroutines.runBlocking { reopenDocxComment(kotlinTarget.absolutePath, path, args.getString("id"), home) } + assertTrue(r is DocxWriteResult.Ok, "$caseName: Kotlin reopen failed: $r") + Unit + } + "move" -> { + val r = kotlinx.coroutines.runBlocking { + moveDocxComment(kotlinTarget.absolutePath, path, args.getString("id"), textSelector(args.getString("newExact")), home) + } + assertTrue(r is DocxWriteResult.Ok, "$caseName: Kotlin move failed: $r") + Unit + } + // Edit/delete build (2026-09-28, design doc §"Edit and delete") — + // this task's own T21 parity extension, previously "not extended + // in this pass". + "edit" -> { + val r = kotlinx.coroutines.runBlocking { editDocxComment(kotlinTarget.absolutePath, path, args.getString("id"), args.getString("text"), home) } + assertTrue(r is DocxWriteResult.Ok, "$caseName: Kotlin edit failed: $r") + Unit + } + "edit-reply" -> { + val r = kotlinx.coroutines.runBlocking { + editDocxReply(kotlinTarget.absolutePath, path, args.getString("id"), args.getString("replyId"), args.getString("text"), home) + } + assertTrue(r is DocxWriteResult.Ok, "$caseName: Kotlin edit-reply failed: $r") + Unit + } + "delete" -> { + val r = kotlinx.coroutines.runBlocking { deleteDocxComment(kotlinTarget.absolutePath, path, args.getString("id"), home) } + assertTrue(r is DocxWriteResult.Ok, "$caseName: Kotlin delete failed: $r") + Unit + } + "delete-reply" -> { + val r = kotlinx.coroutines.runBlocking { + deleteDocxReply(kotlinTarget.absolutePath, path, args.getString("id"), args.getString("replyId"), home) + } + assertTrue(r is DocxWriteResult.Ok, "$caseName: Kotlin delete-reply failed: $r") + Unit + } + else -> fail("unknown op $op") + } + + // The new comment's own id (add) is fully deterministic — both + // writers scan the SAME original fixture's own max existing w:id. + if (op == "add") { + assertEquals(desktopResult.getString("id"), actualIdOrUnit as String, "$caseName: new comment id must match desktop's") + } + + // ── Structural parity, via THIS module's own reader on both sides ── + val desktopGoldenFile = scratchCopyOf(resourceBytes("write-golden/$caseName.docx"), "$caseName-desktop.docx") + val desktopRead = readDocxComments(desktopGoldenFile, path) + val kotlinRead = readDocxComments(kotlinTarget, path) + assertTrue(desktopRead is DocxReadResult.Ok, "$caseName: Kotlin reader failed on DESKTOP's own output: $desktopRead") + assertTrue(kotlinRead is DocxReadResult.Ok, "$caseName: Kotlin reader failed on Kotlin's own output: $kotlinRead") + val desktopComments = (desktopRead as DocxReadResult.Ok).comments.sortedBy { it.id } + val kotlinComments = (kotlinRead as DocxReadResult.Ok).comments.sortedBy { it.id } + assertEquals(desktopComments.size, kotlinComments.size, "$caseName: comment count differs") + for (i in desktopComments.indices) { + assertSameIgnoringTimestamps(desktopComments[i], kotlinComments[i], "$caseName comments[$i]") + } + + // ── Every UNTOUCHED part is byte-identical: desktop's output == + // Kotlin's output == the ORIGINAL fixture, for every part name + // neither writer's own algorithm is allowed to touch. ── + val originalFile = scratchCopyOf(originalBytes, "orig-$caseName.docx") + val namesToCheck = (entryNames(desktopGoldenFile) + entryNames(kotlinTarget)) - TOUCHED_PART_NAMES + for (name in namesToCheck) { + val original = entryBytes(originalFile, name) + val desktopBytes = entryBytes(desktopGoldenFile, name) + val kotlinBytes = entryBytes(kotlinTarget, name) + assertTrue(desktopBytes != null && kotlinBytes != null, "$caseName: part $name missing from one output") + assertTrue(desktopBytes!!.contentEquals(kotlinBytes), "$caseName: part $name differs between desktop and Kotlin output") + if (original != null) { + assertTrue(original.contentEquals(kotlinBytes), "$caseName: part $name was touched by the Kotlin writer but shouldn't have been") + } + } + } + + @Test fun addLaunchBrief() = runCase("add-launch-brief") + @Test fun addSpanningComment() = runCase("add-spanning-comment") + @Test fun addWord365Realistic() = runCase("add-word365-realistic") + @Test fun replyLaunchBrief() = runCase("reply-launch-brief") + @Test fun resolveSpanningComment() = runCase("resolve-spanning-comment") + @Test fun reopenLaunchBrief() = runCase("reopen-launch-brief") + @Test fun moveLaunchBrief() = runCase("move-launch-brief") + @Test fun moveWord365Realistic() = runCase("move-word365-realistic") + + // Edit/delete build (2026-09-28, design doc §"Edit and delete") — this + // task's own T21 parity extension, previously "not extended in this + // pass" per that section's own closing note. All four reuse launch- + // brief.docx's real w-0 (no reply) and w-1 (one reply, w-1-r1). + @Test fun editLaunchBrief() = runCase("edit-launch-brief") + @Test fun editReplyLaunchBrief() = runCase("edit-reply-launch-brief") + @Test fun deleteReplyLaunchBrief() = runCase("delete-reply-launch-brief") + // Deletes w-1's WHOLE thread — root + its one reply — the direct proof + // that a delete removes every reply chained to a root via + // w15:paraIdParent, not just the root's own document.xml anchor. + @Test fun deleteThreadLaunchBrief() = runCase("delete-thread-launch-brief") +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/DocxCommentsTest.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/DocxCommentsTest.kt new file mode 100644 index 000000000..6b1078823 --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/DocxCommentsTest.kt @@ -0,0 +1,369 @@ +// Pins T16 of the doc-comments build (docs/active/specs/2026-09-26-doc- +// comments-build-design.md §3.2a, §8): reading Word comments off a REAL +// .docx's comments.xml/commentsExtended.xml parts, in Kotlin, with +// java.util.zip + javax.xml.parsers — no Android framework classes, no +// instrumented/on-device test (a plain `./gradlew test` JVM unit test, per +// T16's own pinning-test row). +// +// Fixtures: app/src/test/resources/doc-comments/{launch-brief,no-comments, +// spanning-comment,deeply-nested,word365-realistic}.docx — the SAME files +// desktop/tests/docx-comments.test.ts reads (copied verbatim from +// desktop/tests/fixtures/doc-comments/), so a fixture drift here can never +// silently make the cross-platform parity claim compare two different +// inputs (the exact risk T16's own design row warns about). +// +// Golden parity: app/src/test/resources/doc-comments/golden/*.json is the +// REAL desktop `readDocxComments` reader's own output for each fixture, +// generated by desktop/tests/fixtures/doc-comments/generate-docx-golden.mjs +// (committed alongside this test — see that script's header for why it runs +// the actual TS reader rather than hand-transcribing expected JSON). Every +// golden-parity test below re-parses the golden JSON and asserts this +// module's own `PersistedComment.toJson()` output is structurally IDENTICAL, +// field for field. +package com.youcoded.app.doccomments + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +private fun fixtureFile(name: String): File { + val resourceStream = object {}.javaClass.getResourceAsStream("/doc-comments/$name") + ?: fail("missing test resource doc-comments/$name") + val tmp = Files.createTempFile("ycd-docx-read-", "-$name").toFile() + tmp.deleteOnExit() + resourceStream.use { input -> tmp.outputStream().use { output -> input.copyTo(output) } } + return tmp +} + +private fun goldenComments(name: String): JSONArray { + val stream = object {}.javaClass.getResourceAsStream("/doc-comments/golden/$name") + ?: fail("missing golden fixture doc-comments/golden/$name") + val json = stream.use { it.readBytes().toString(Charsets.UTF_8) } + return JSONObject(json).getJSONArray("comments") +} + +/** Deep-equality between two JSON trees (`org.json` gives no `equals()` that + * compares by value — `JSONObject.equals` is reference identity). Object + * keys are compared as sets before recursing (so a golden file with an + * extra/missing field fails loudly rather than passing on a subset match), + * arrays are compared in order (field order is never significant, item + * order always is — matches how every list in `PersistedComment` — replies, + * history, comments — is itself order-significant). */ +// `JSONObject.keySet()` was only added to upstream org.json well after +// Android bundled its own fork into `android.jar` (which never got it) — the +// unit-test compile classpath resolves `org.json.JSONObject` against +// whichever jar Gradle orders first, which turned out to be `android.jar`'s +// older API surface (confirmed by an `Unresolved reference 'keySet'` compile +// failure while building this test). `.keys()` (an `Iterator`) is +// the one key-enumeration method present on BOTH android.jar's org.json and +// the real `org.json:json` test dependency, so this helper uses that instead. +private fun keysOf(o: JSONObject): Set { + val out = mutableSetOf() + val it = o.keys() + while (it.hasNext()) out.add(it.next()) + return out +} + +private fun assertJsonEquals(expected: Any?, actual: Any?, path: String) { + when (expected) { + is JSONObject -> { + assertTrue(actual is JSONObject, "$path: expected an object, got $actual") + actual as JSONObject + val expectedKeys = keysOf(expected) + assertEquals(expectedKeys, keysOf(actual), "$path: key set differs") + for (key in expectedKeys) { + assertJsonEquals(expected.get(key), actual.get(key), "$path.$key") + } + } + is JSONArray -> { + assertTrue(actual is JSONArray, "$path: expected an array, got $actual") + actual as JSONArray + assertEquals(expected.length(), actual.length(), "$path: array length differs") + for (i in 0 until expected.length()) { + assertJsonEquals(expected.get(i), actual.get(i), "$path[$i]") + } + } + else -> assertEquals(expected, actual, path) + } +} + +private fun assertMatchesGolden(fixtureBaseName: String, comments: List) { + val expected = goldenComments("$fixtureBaseName.json") + val actual = JSONArray(comments.map { it.toJson() }) + assertJsonEquals(expected, actual, fixtureBaseName) +} + +class DocxCommentsTest { + + // ── Golden parity against the REAL desktop reader's own output ───────── + + @Test + fun launchBriefMatchesTheDesktopReaderFieldForField() { + val result = readDocxComments(fixtureFile("launch-brief.docx"), "docs/launch-brief.docx") + assertTrue(result is DocxReadResult.Ok, "expected Ok, got $result") + assertMatchesGolden("launch-brief", (result as DocxReadResult.Ok).comments) + } + + @Test + fun noCommentsMatchesTheDesktopReaderFieldForField() { + val result = readDocxComments(fixtureFile("no-comments.docx"), "docs/no-comments.docx") + assertTrue(result is DocxReadResult.Ok, "expected Ok, got $result") + assertMatchesGolden("no-comments", (result as DocxReadResult.Ok).comments) + } + + @Test + fun spanningCommentMatchesTheDesktopReaderFieldForField() { + val result = readDocxComments(fixtureFile("spanning-comment.docx"), "docs/spanning-comment.docx") + assertTrue(result is DocxReadResult.Ok, "expected Ok, got $result") + assertMatchesGolden("spanning-comment", (result as DocxReadResult.Ok).comments) + } + + @Test + fun deeplyNestedMatchesTheDesktopReaderFieldForField() { + val result = readDocxComments(fixtureFile("deeply-nested.docx"), "docs/deeply-nested.docx") + assertTrue(result is DocxReadResult.Ok, "expected Ok, got $result") + assertMatchesGolden("deeply-nested", (result as DocxReadResult.Ok).comments) + } + + @Test + fun word365RealisticMatchesTheDesktopReaderFieldForField() { + val result = readDocxComments(fixtureFile("word365-realistic.docx"), "docs/word365-realistic.docx") + assertTrue(result is DocxReadResult.Ok, "expected Ok, got $result") + assertMatchesGolden("word365-realistic", (result as DocxReadResult.Ok).comments) + } + + // ── Behavioural pins mirroring desktop/tests/docx-comments.test.ts ───── + + @Test + fun readsBothTopLevelCommentsWithTheirAuthorsTextAndResolvedState() { + val result = readDocxComments(fixtureFile("launch-brief.docx"), "docs/launch-brief.docx") + assertTrue(result is DocxReadResult.Ok, "expected Ok, got $result") + val comments = (result as DocxReadResult.Ok).comments + assertEquals(2, comments.size) + + val goalComment = comments.find { it.text.startsWith("Is 30% realistic") } + assertTrue(goalComment != null, "expected a comment starting with 'Is 30% realistic'") + assertEquals("person:Priya Shah", goalComment!!.author) + assertTrue(goalComment.resolved, "w15:done=\"1\" should read as resolved") + assertTrue(goalComment.replies.isEmpty()) + assertEquals("docs/launch-brief.docx", goalComment.path) + val selector = goalComment.selector + assertTrue(selector is CommentSelector.Text, "expected a text selector") + assertEquals( + "Move 30% of weekly active users to the new app within six weeks of launch.", + (selector as CommentSelector.Text).selector.exact, + ) + } + + @Test + fun reconstructsAReplyThreadViaParaIdParent() { + val result = readDocxComments(fixtureFile("launch-brief.docx"), "docs/launch-brief.docx") + assertTrue(result is DocxReadResult.Ok, "expected Ok, got $result") + val comments = (result as DocxReadResult.Ok).comments + + val timelineComment = comments.find { it.text.startsWith("Legal needs the beta terms") } + assertTrue(timelineComment != null) + assertTrue(!timelineComment!!.resolved, "w15:done=\"0\" should read as not resolved") + assertEquals(1, timelineComment.replies.size) + val reply = timelineComment.replies[0] + assertEquals("person:Marcus Lee", reply.author) + assertEquals("Confirmed with legal — the beta terms are ready as of this morning.", reply.text) + // Account-ready id shape (§1.2): `${commentId}-r${n}`. + assertEquals("${timelineComment.id}-r1", reply.id) + // The reply itself is never a separate top-level record. + assertTrue(comments.none { it.text.contains("Confirmed with legal") }) + } + + @Test + fun buildsATextQuoteSelectorWithPrefixSuffixContext() { + val result = readDocxComments(fixtureFile("launch-brief.docx"), "docs/launch-brief.docx") + assertTrue(result is DocxReadResult.Ok, "expected Ok, got $result") + val comments = (result as DocxReadResult.Ok).comments + val c = comments.find { it.text.startsWith("Legal needs") } + assertTrue(c != null) + val selector = c!!.selector + assertTrue(selector is CommentSelector.Text, "expected a text selector") + val text = (selector as CommentSelector.Text).selector + assertEquals("Beta opens to 500 customers on March 10", text.exact) + assertTrue(text.prefix.isNotEmpty()) + assertTrue(text.suffix.contains(", with the public launch")) + } + + @Test + fun aDocxWithNoCommentsXmlPartReturnsAnEmptyListNeverACrash() { + val result = readDocxComments(fixtureFile("no-comments.docx"), "docs/no-comments.docx") + assertTrue(result is DocxReadResult.Ok, "expected Ok, got $result") + assertEquals(emptyList(), (result as DocxReadResult.Ok).comments) + } + + @Test + fun notAValidZipRefusesHonestlyInsteadOfThrowing() { + val garbage = Files.createTempFile("ycd-docx-read-", "-garbage.docx").toFile() + garbage.deleteOnExit() + garbage.writeBytes("not a docx".toByteArray(Charsets.UTF_8)) + val result = readDocxComments(garbage, "docs/garbage.docx") + assertEquals(DocxReadResult.Err(DocxReadError.INVALID_DOCX), result) + } + + @Test + fun refusesWordCommentsXmlDeclaringAnImplausibleUncompressedSize() { + val bytes = buildDeclaredOversizeZip( + mapOf( + "word/document.xml" to "", + "word/comments.xml" to "", + ), + "word/comments.xml", + 500L * 1024 * 1024, + ) + val f = Files.createTempFile("ycd-docx-read-", "-bomb.docx").toFile() + f.deleteOnExit() + f.writeBytes(bytes) + val result = readDocxComments(f, "docs/bomb.docx") + assertEquals(DocxReadResult.Err(DocxReadError.ARCHIVE_TOO_LARGE), result) + } + + @Test + fun refusesWordDocumentXmlDeclaringAnImplausibleUncompressedSize() { + val bytes = buildDeclaredOversizeZip( + mapOf( + "word/document.xml" to "", + "word/comments.xml" to "", + ), + "word/document.xml", + 500L * 1024 * 1024, + ) + val f = Files.createTempFile("ycd-docx-read-", "-bomb.docx").toFile() + f.deleteOnExit() + f.writeBytes(bytes) + val result = readDocxComments(f, "docs/bomb.docx") + assertEquals(DocxReadResult.Err(DocxReadError.ARCHIVE_TOO_LARGE), result) + } + + // F1 (implementation review — high, security): a part carrying an + // internal-entity "billion laughs" DTD bomb must be refused, not parsed — + // see `rejectDoctype`'s own doc comment (DocxComments.kt) for why this + // pre-check exists ON TOP OF the factory's own `disallow-doctype-decl` + // feature: this JVM test's own Xerces-backed parser WOULD also refuse the + // DOCTYPE on its own (confirming this exact scenario can never surface a + // real bug from `./gradlew test` alone), but Android's actual on-device + // Expat-backed parser may silently ignore that feature name entirely, + // which is exactly what this platform-independent substring check exists + // to catch regardless of which parser implementation is on the classpath. + @Test + fun refusesADocxWhoseDocumentXmlCarriesAnInternalDtdEntityBomb() { + val doctypeBomb = """ + + + + + ]> + &lol3; + """.trimIndent() + val bytes = buildPlainZip( + mapOf( + "word/document.xml" to doctypeBomb, + "word/comments.xml" to "", + ), + ) + val f = Files.createTempFile("ycd-docx-read-", "-doctype-bomb.docx").toFile() + f.deleteOnExit() + f.writeBytes(bytes) + val result = readDocxComments(f, "docs/doctype-bomb.docx") + assertEquals(DocxReadResult.Err(DocxReadError.UNSAFE_XML), result) + } + + @Test + fun refusesACommentsXmlCarryingAnInternalDtdEntityBomb() { + val doctypeBomb = """ + + ]> + &lol2; + """.trimIndent() + val bytes = buildPlainZip( + mapOf( + "word/document.xml" to "", + "word/comments.xml" to doctypeBomb, + ), + ) + val f = Files.createTempFile("ycd-docx-read-", "-doctype-bomb2.docx").toFile() + f.deleteOnExit() + f.writeBytes(bytes) + val result = readDocxComments(f, "docs/doctype-bomb2.docx") + assertEquals(DocxReadResult.Err(DocxReadError.UNSAFE_XML), result) + } + + // F3 (implementation review): the declared-size precheck + // (`checkNamedEntriesWithinCeiling`) only ever reads CENTRAL DIRECTORY + // metadata — a crafted entry can simply lie about it. This builds a REAL + // zip bomb (word/comments.xml genuinely decompresses to well over the + // 200MB ceiling) whose declared size is patched down to a few bytes, so + // the declared-size precheck alone would wave it through; only the + // byte-counting `readEntryBounded` backstop can catch this shape. + @Test + fun refusesACommentsXmlEntryWhoseRealDecompressedBytesExceedTheCeilingEvenWhenItsDeclaredSizeIsTiny() { + val bytes = buildRealZipBomb( + files = mapOf("word/document.xml" to ""), + bombEntryName = "word/comments.xml", + realUncompressedBytes = 201L * 1024 * 1024, + declaredUncompressedSize = 10L, + ) + val f = Files.createTempFile("ycd-docx-read-", "-real-bomb.docx").toFile() + f.deleteOnExit() + f.writeBytes(bytes) + val result = readDocxComments(f, "docs/real-bomb.docx") + assertEquals(DocxReadResult.Err(DocxReadError.ARCHIVE_TOO_LARGE), result) + } + + @Test + fun findsACommentNestedThousandsOfLevelsDeepWithoutOverflowingTheCallStack() { + val result = readDocxComments(fixtureFile("deeply-nested.docx"), "docs/deeply-nested.docx") + assertTrue(result is DocxReadResult.Ok, "expected Ok, got $result") + val comments = (result as DocxReadResult.Ok).comments + assertEquals(1, comments.size) + assertEquals("A comment buried very deep in the document tree.", comments[0].text) + val selector = comments[0].selector + assertTrue(selector is CommentSelector.Text) + assertEquals( + "A comment buried very deep in the document tree.", + (selector as CommentSelector.Text).selector.exact, + ) + } + + @Test + fun capturesTheExactQuoteAcrossAParagraphBreakWithCorrectPrefixSuffix() { + val result = readDocxComments(fixtureFile("spanning-comment.docx"), "docs/spanning-comment.docx") + assertTrue(result is DocxReadResult.Ok, "expected Ok, got $result") + val comments = (result as DocxReadResult.Ok).comments + assertEquals(1, comments.size) + val c = comments[0] + assertEquals("Should this be one paragraph instead of two?", c.text) + val selector = c.selector + assertTrue(selector is CommentSelector.Text) + val text = (selector as CommentSelector.Text).selector + // The exact quote spans the paragraph break: text from two runs in + // paragraph 1, the paragraph-break newline, then text from two more + // runs in paragraph 2. + assertEquals("first half \nsecond half.", text.exact) + assertTrue(text.prefix.contains("Before the change:")) + assertTrue(text.suffix.contains("After the note.")) + } + + @Test + fun runsInAPlainJvmEnvironmentNoAndroidFrameworkClassInvolved() { + // This is the whole point of T16's own pinning-test row ("a JVM unit + // test, not an instrumented/on-device test, proving this runs in + // plain ./gradlew test"): if this test class needed a real Android + // Context, Robolectric, or android.* framework classes beyond the + // stub jar's compile-time surface, it would fail to load or run + // under a plain `./gradlew test` invocation. Reaching this + // assertion at all is the pin. + val result = readDocxComments(fixtureFile("no-comments.docx"), "docs/no-comments.docx") + assertTrue(result is DocxReadResult.Ok) + } +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/DocxCommentsWriteTest.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/DocxCommentsWriteTest.kt new file mode 100644 index 000000000..62864e0f1 --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/DocxCommentsWriteTest.kt @@ -0,0 +1,1032 @@ +// Pins T17 of the doc-comments build (docs/active/specs/2026-09-26-doc- +// comments-build-design.md §3.2a/§3.3, §8): WRITING Word comments (add / +// reply / resolve / reopen / move) off a REAL .docx, in Kotlin, with +// java.util.zip + javax.xml.parsers/transform — mirrors +// desktop/tests/docx-comments.test.ts's own T11 write suite test-for-test so +// this Kotlin build is held to the identical bar ("byte-identical rollback on +// verify failure", gapped/non-sequential ids, a move that relocates a range) +// rather than a lighter one because it's "just the phone" (T17's own design +// row). +// +// Fixtures: the SAME .docx files DocxCommentsTest.kt (T16) already reads — +// app/src/test/resources/doc-comments/{launch-brief,no-comments,spanning- +// comment,word365-realistic}.docx — copied verbatim from desktop/tests/ +// fixtures/doc-comments/, so this suite mutates the identical bytes desktop's +// own T11 suite mutates. +package com.youcoded.app.doccomments + +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.File +import java.nio.file.Files +import java.util.zip.ZipFile +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** Extracts a checked-in fixture into a FRESH scratch file, own directory per + * call — every write test gets its own isolated copy, mutated in place via + * its own absolute path, mirroring desktop's `withScratchCopy`. */ +private fun scratchCopy(name: String): File { + val resourceStream = object {}.javaClass.getResourceAsStream("/doc-comments/$name") + ?: fail("missing test resource doc-comments/$name") + val dir = Files.createTempDirectory("ycd-docx-write-").toFile() + dir.deleteOnExit() + val target = File(dir, name) + resourceStream.use { input -> target.outputStream().use { output -> input.copyTo(output) } } + return target +} + +/** A fresh, isolated `homeDir` per test — backups land under + * `/.claude/youcoded-doc-backups/`, never colliding across tests or + * with a real `~/.claude`. */ +private fun scratchHomeDir(): File { + val dir = Files.createTempDirectory("ycd-docx-write-home-").toFile() + dir.deleteOnExit() + return dir +} + +private fun textSelector(exact: String): CommentSelector.Text = + CommentSelector.Text(TextQuoteSelector(exact = exact, prefix = "", suffix = "", occurrence = 0)) + +private fun partText(file: File, entryName: String): String? { + ZipFile(file).use { zip -> + val entry = zip.getEntry(entryName) ?: return null + return zip.getInputStream(entry).use { it.readBytes().toString(Charsets.UTF_8) } + } +} + +private const val W_NS_ATTR = """xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"""" +private const val W14_NS_ATTR = """xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"""" + +/** F5 (T17 review, minor): every write test below calls this on its output + * archive — a DOM-PARSE well-formedness check on every part a write could + * touch, not just the field-level assertions `readDocxComments` gives us. + * `readDocxComments` walks the DOM with `getElementsByTagName`, which is + * perfectly happy with plenty of XML shapes; parsing each part fresh with a + * strict `DocumentBuilder` (the same secure parser this module's OWN write + * path already builds documents with) catches a mutation that produced + * technically-walkable-by-this-code-but-not-well-formed markup — e.g. an + * unescaped `&` slipped into `w:t` text, or an unbalanced element from a + * `splitRunAtOffsets`/anchor bug — even though every other assertion in this + * file would still pass. Parts that don't exist in a given fixture (e.g. no + * `commentsExtended.xml` before the first resolve/reopen) are skipped, not + * failed. */ +private fun assertPartsWellFormed(file: File) { + val partNames = listOf( + "word/document.xml", + "word/comments.xml", + "word/commentsExtended.xml", + "[Content_Types].xml", + "word/_rels/document.xml.rels", + "word/commentsIds.xml", + "word/commentsExtensible.xml", + ) + ZipFile(file).use { zip -> + for (name in partNames) { + val entry = zip.getEntry(name) ?: continue + val bytes = zip.getInputStream(entry).use { it.readBytes() } + try { + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(ByteArrayInputStream(bytes)) + } catch (e: Exception) { + fail("$name is not well-formed XML after this write: ${e.message}") + } + } + } +} + +/** Mirrors desktop's own `buildGappedIdsDocx` (docx-comments.test.ts) field + * for field: a hand-assembled real zip archive with two comments at ids 3 + * and 7 (a gap, and starting above 0) — built directly rather than via a + * checked-in binary, since it exists only to pin `maxExistingCommentId`'s + * "(max existing w:id) + 1, never assumed monotonic" rule. */ +private fun buildGappedIdsDocx(): File { + val dir = Files.createTempDirectory("ycd-docx-write-gapped-").toFile() + dir.deleteOnExit() + val target = File(dir, "gapped.docx") + java.util.zip.ZipOutputStream(target.outputStream()).use { zos -> + fun entry(name: String, content: String) { + zos.putNextEntry(java.util.zip.ZipEntry(name)) + zos.write(content.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + entry( + "[Content_Types].xml", + """""", + ) + entry( + "_rels/.rels", + """""", + ) + entry( + "word/_rels/document.xml.rels", + """""", + ) + entry( + "word/document.xml", + """""" + + """First commented run.""" + + """Second commented run.""" + + """A brand new sentence with nothing commented yet.""" + + """""", + ) + entry( + "word/comments.xml", + """""" + + """First note.""" + + """Second note.""" + + """""", + ) + } + return target +} + +class DocxCommentsWriteTest { + + // ── add ────────────────────────────────────────────────────────────── + + @Test + fun `adds a new comment and it round-trips through the same reader`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = addDocxComment( + target.absolutePath, "docs/launch-brief.docx", + textSelector("Marketing emails go out the same morning as the public launch."), + "Confirm the send time with marketing.", "user", home, + ) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + val newId = (result as DocxWriteResult.Ok).value + assertTrue(Regex("^w-\\d+$").matches(newId)) + + val read = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(read is DocxReadResult.Ok, "expected Ok, got $read") + val comments = (read as DocxReadResult.Ok).comments + val added = comments.find { it.id == newId } + assertNotNull(added) + assertEquals("Confirm the send time with marketing.", added.text) + assertEquals("person:You", added.author) + assertFalse(added.resolved) + val selector = added.selector + assertTrue(selector is CommentSelector.Text) + assertEquals("Marketing emails go out the same morning as the public launch.", (selector as CommentSelector.Text).selector.exact) + + // Existing Word-authored comments are preserved byte-for-byte in + // their OWN fields. + val untouched = comments.find { it.id == "w-0" } + assertNotNull(untouched) + assertEquals("person:Priya Shah", untouched.author) + assertTrue(untouched.resolved) + assertTrue(untouched.text.contains("Is 30% realistic")) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `creates comments xml plus its content-types override and relationship from scratch`() = runTest { + val target = scratchCopy("no-comments.docx") + val home = scratchHomeDir() + ZipFile(target).use { zip -> assertNull(zip.getEntry("word/comments.xml")) } + + val result = addDocxComment( + target.absolutePath, "docs/no-comments.docx", + textSelector("Nothing here has ever been commented on."), + "First comment this file has ever had.", "assistant", home, + ) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + + ZipFile(target).use { zip -> assertNotNull(zip.getEntry("word/comments.xml")) } + assertTrue(partText(target, "[Content_Types].xml")!!.contains("/word/comments.xml")) + assertTrue(partText(target, "word/_rels/document.xml.rels")!!.contains("comments.xml")) + + val read = readDocxComments(target, "docs/no-comments.docx") + assertTrue(read is DocxReadResult.Ok, "expected Ok, got $read") + val comments = (read as DocxReadResult.Ok).comments + assertEquals(1, comments.size) + assertEquals("person:Assistant", comments[0].author) + assertEquals("First comment this file has ever had.", comments[0].text) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `a comment spanning multiple runs and two paragraphs inserts correctly around an existing range`() = runTest { + val target = scratchCopy("spanning-comment.docx") + val home = scratchHomeDir() + val result = addDocxComment( + target.absolutePath, "docs/spanning-comment.docx", + textSelector("half \nsecond"), + "A second, independent multi-paragraph comment.", "user", home, + ) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + val newId = (result as DocxWriteResult.Ok).value + + val read = readDocxComments(target, "docs/spanning-comment.docx") + assertTrue(read is DocxReadResult.Ok, "expected Ok, got $read") + val comments = (read as DocxReadResult.Ok).comments + assertEquals(2, comments.size) + val added = comments.find { it.id == newId } + assertNotNull(added) + val addedSel = added.selector + assertTrue(addedSel is CommentSelector.Text) + assertEquals("half \nsecond", (addedSel as CommentSelector.Text).selector.exact) + + val original = comments.find { it.id == "w-5" } + assertNotNull(original) + assertEquals("Should this be one paragraph instead of two?", original.text) + val originalSel = original.selector + assertTrue(originalSel is CommentSelector.Text) + assertEquals("first half \nsecond half.", (originalSel as CommentSelector.Text).selector.exact) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `a comment on text strictly inside a single run splits that run correctly`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = addDocxComment(target.absolutePath, "docs/launch-brief.docx", textSelector("30%"), "Just the percentage.", "user", home) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + val newId = (result as DocxWriteResult.Ok).value + + val read = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(read is DocxReadResult.Ok, "expected Ok, got $read") + val comments = (read as DocxReadResult.Ok).comments + val added = comments.find { it.id == newId } + assertNotNull(added) + val addedSel = added.selector as CommentSelector.Text + assertEquals("30%", addedSel.selector.exact) + assertTrue(addedSel.selector.prefix.contains("Move")) + assertTrue(addedSel.selector.suffix.contains("of weekly")) + + val original = comments.find { it.id == "w-0" } + assertNotNull(original) + val originalSel = original.selector as CommentSelector.Text + assertEquals("Move 30% of weekly active users to the new app within six weeks of launch.", originalSel.selector.exact) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `mints max existing w id plus one against a gapped non-sequential id set, never a naive count`() = runTest { + // Built in-memory rather than as a checked-in binary, mirroring + // desktop's own `buildGappedIdsDocx` (docx-comments.test.ts): two + // comments, ids 3 and 7 (a gap, and starting above 0) — a naive + // `comments.length` counter would mint `2` next; the correct next + // id is `8`. + val target = buildGappedIdsDocx() + val home = scratchHomeDir() + val result = addDocxComment( + target.absolutePath, "docs/gapped.docx", + textSelector("A brand new sentence with nothing commented yet."), + "x", "user", home, + ) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + assertEquals("w-8", (result as DocxWriteResult.Ok).value) // existing ids are 3 and 7 — next is 8, not 2 + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `refuses a selector that does not resolve, leaving the file byte-identical`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val before = target.readBytes() + val result = addDocxComment( + target.absolutePath, "docs/launch-brief.docx", + textSelector("this text does not exist anywhere in the document"), + "x", "user", home, + ) + assertEquals(DocxWriteResult.Err(DocxWriteError.SELECTOR_NOT_FOUND), result) + assertTrue(target.readBytes().contentEquals(before)) + } + + @Test + fun `refuses a cell selector against a Word target rather than silently coercing it`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = addDocxComment( + target.absolutePath, "docs/launch-brief.docx", + CommentSelector.Cell(CellSelector(cell = "A1")), + "x", "user", home, + ) + assertEquals(DocxWriteResult.Err(DocxWriteError.INVALID_SELECTOR), result) + } + + // ── reply ──────────────────────────────────────────────────────────── + + @Test + fun `appends a reply, preserving the existing reply thread`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + // T5 review parity (design §1.6, F2): `reply` returns the real + // persisted `CommentReply` — its own `author` is the CALLER's + // original value ('user'), not the round-tripped Word display name + // ('person:You') `readDocxComments` maps it to on the way back out, + // mirroring desktop's own docx-comments.test.ts assertion. + val result = replyToDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-1", "Sounds good, thanks both.", "user", home) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + val reply = (result as DocxWriteResult.Ok).value + assertEquals(CommentReply("w-1-r2", "user", "Sounds good, thanks both.", reply.createdAt), reply) + + val read = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(read is DocxReadResult.Ok, "expected Ok, got $read") + val comment1 = (read as DocxReadResult.Ok).comments.find { it.id == "w-1" } + assertNotNull(comment1) + assertEquals(2, comment1.replies.size) + assertEquals("person:Marcus Lee", comment1.replies[0].author) // the ORIGINAL reply, unmoved + assertEquals("person:You", comment1.replies[1].author) + assertEquals("Sounds good, thanks both.", comment1.replies[1].text) + assertEquals(reply.id, comment1.replies[1].id) // the enriched response's own id matches the read path's + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `reply refuses an id this file does not have`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = replyToDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-999", "x", "user", home) + assertEquals(DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND), result) + } + + // ── strips XML-illegal control characters (2026-09-27) ───────────────── + // Originally (commit ffda4b654) this REFUSED text containing one of + // these characters — see `stripIllegalXmlChars`'s own doc comment for + // why that changed to a silent strip (a user can't see or remove one + // themselves; it arrives via paste). + + @Test + fun `add strips illegal control characters and persists the stripped text`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = addDocxComment( + target.absolutePath, "docs/launch-brief.docx", + textSelector("Marketing emails go out the same morning as the public launch."), + "before\u0001\u0002\u001Fafter", "user", home, + ) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + val newId = (result as DocxWriteResult.Ok).value + val read = readDocxComments(target, "docs/launch-brief.docx") + val added = (read as DocxReadResult.Ok).comments.find { it.id == newId } + assertEquals("beforeafter", added?.text) + } + + @Test + fun `reply strips illegal control characters and persists the stripped text`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = replyToDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-1", "x\u0000y", "user", home) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + assertEquals("xy", (result as DocxWriteResult.Ok).value.text) + } + + @Test + fun `add still allows tab, newline and carriage return in comment text`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = addDocxComment( + target.absolutePath, "docs/launch-brief.docx", + textSelector("Marketing emails go out the same morning as the public launch."), + "line one\nline two\ttabbed\rcr", "user", home, + ) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + } + + // ── resolve / reopen ───────────────────────────────────────────────── + + @Test + fun `resolve sets w15 done, reopen clears it, and either creates commentsExtended xml if absent`() = runTest { + val target = scratchCopy("spanning-comment.docx") + val home = scratchHomeDir() + ZipFile(target).use { zip -> assertNull(zip.getEntry("word/commentsExtended.xml")) } + + val resolved = resolveDocxComment(target.absolutePath, "docs/spanning-comment.docx", "w-5", home) + assertEquals(DocxWriteResult.Ok(Unit), resolved) + var read = readDocxComments(target, "docs/spanning-comment.docx") + assertTrue(read is DocxReadResult.Ok && read.comments[0].resolved) + + val reopened = reopenDocxComment(target.absolutePath, "docs/spanning-comment.docx", "w-5", home) + assertEquals(DocxWriteResult.Ok(Unit), reopened) + read = readDocxComments(target, "docs/spanning-comment.docx") + assertTrue(read is DocxReadResult.Ok && !read.comments[0].resolved) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `resolving an already-resolved comment updates the existing entry rather than duplicating it`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + // id 0 starts resolved (w15:done="1") — reopen then resolve again. + reopenDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-0", home) + resolveDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-0", home) + val extendedXml = partText(target, "word/commentsExtended.xml")!! + assertEquals(1, Regex("w15:paraId=\"10000000\"").findAll(extendedXml).count()) // still exactly one entry + + val read = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(read is DocxReadResult.Ok, "expected Ok, got $read") + val comments = (read as DocxReadResult.Ok).comments + assertTrue(comments.find { it.id == "w-0" }!!.resolved) + // Unrelated entries (id 1's reply-linking) are untouched. + val t1 = comments.find { it.id == "w-1" } + assertNotNull(t1) + assertFalse(t1.resolved) + assertEquals(1, t1.replies.size) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `resolve refuses an id this file does not have`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = resolveDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-999", home) + assertEquals(DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND), result) + } + + // ── move ───────────────────────────────────────────────────────────── + + @Test + fun `move relocates a comment - old range gone, new one resolves, id replies resolve state unchanged`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = moveDocxComment( + target.absolutePath, "docs/launch-brief.docx", "w-1", + textSelector("Keep support tickets about the update below 200 per week."), home, + ) + assertEquals(DocxWriteResult.Ok(Unit), result) + + val documentXml = partText(target, "word/document.xml")!! + assertFalse(Regex("Beta opens to 500 customers[\\s\\S]{0,80}w:id=\"1\"").containsMatchIn(documentXml)) + assertEquals(1, Regex("w:commentRangeStart w:id=\"1\"").findAll(documentXml).count()) + assertEquals(1, Regex("w:commentRangeEnd w:id=\"1\"").findAll(documentXml).count()) + assertEquals(1, Regex("w:commentReference w:id=\"1\"").findAll(documentXml).count()) + + val read = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(read is DocxReadResult.Ok, "expected Ok, got $read") + val moved = (read as DocxReadResult.Ok).comments.find { it.id == "w-1" } + assertNotNull(moved) + val movedSel = moved.selector as CommentSelector.Text + assertEquals("Keep support tickets about the update below 200 per week.", movedSel.selector.exact) + assertEquals(1, moved.replies.size) + assertEquals("person:Marcus Lee", moved.replies[0].author) + assertFalse(moved.resolved) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `move refuses when newSelector does not resolve, leaving the file byte-identical`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val before = target.readBytes() + val result = moveDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-1", textSelector("this text is nowhere in the document"), home) + assertEquals(DocxWriteResult.Err(DocxWriteError.SELECTOR_NOT_FOUND), result) + assertTrue(target.readBytes().contentEquals(before)) + } + + @Test + fun `move refuses an id this file does not have`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = moveDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-999", textSelector("Keep support tickets"), home) + assertEquals(DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND), result) + } + + // ── Edit/delete build (2026-09-28, design doc §"Edit and delete") ─────── + // Mirrors desktop's own docx-comments.test.ts `describe('docx-comments + // write — edit', ...)` / `describe('docx-comments write — delete (whole + // thread) leaves a document Word opens', ...)` / `describe('docx-comments + // write — delete-reply', ...)` blocks. launch-brief.docx: w-1 has exactly + // one reply from Marcus Lee; w-0 starts resolved with no replies. + + @Test + fun `editDocxComment replaces the comment paragraph text, keeping author date and the document anchor untouched`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val before = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(before is DocxReadResult.Ok, "expected Ok, got $before") + val beforeComment = (before as DocxReadResult.Ok).comments.find { it.id == "w-1" } + assertNotNull(beforeComment) + + val result = editDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-1", "Rewritten note.", home) + assertEquals(DocxWriteResult.Ok("Rewritten note."), result) + + val after = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(after is DocxReadResult.Ok, "expected Ok, got $after") + val edited = (after as DocxReadResult.Ok).comments.find { it.id == "w-1" } + assertNotNull(edited) + assertEquals("Rewritten note.", edited.text) + // Author/date (and the anchor selector/replies/resolve state) survive + // an edit unchanged — only the text itself changed. + assertEquals(beforeComment.author, edited.author) + assertEquals(beforeComment.createdAt, edited.createdAt) + assertEquals(beforeComment.selector, edited.selector) + assertEquals(beforeComment.replies, edited.replies) + + val documentXml = partText(target, "word/document.xml")!! + assertEquals(1, Regex("w:commentRangeStart w:id=\"1\"").findAll(documentXml).count()) + assertEquals(1, Regex("w:commentReference w:id=\"1\"").findAll(documentXml).count()) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `editDocxComment refuses an id this file does not have`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = editDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-999", "x", home) + assertEquals(DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND), result) + } + + @Test + fun `editDocxReply replaces one reply's text, leaving the parent and its own author date untouched`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val before = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(before is DocxReadResult.Ok, "expected Ok, got $before") + val beforeParent = (before as DocxReadResult.Ok).comments.find { it.id == "w-1" } + assertNotNull(beforeParent) + val beforeReply = beforeParent.replies[0] + + val result = editDocxReply(target.absolutePath, "docs/launch-brief.docx", "w-1", beforeReply.id, "Edited reply.", home) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + assertEquals( + CommentReply(beforeReply.id, beforeReply.author, "Edited reply.", beforeReply.createdAt), + (result as DocxWriteResult.Ok).value, + ) + + val after = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(after is DocxReadResult.Ok, "expected Ok, got $after") + val parent = (after as DocxReadResult.Ok).comments.find { it.id == "w-1" } + assertNotNull(parent) + assertEquals(beforeParent.text, parent.text) // parent untouched + assertEquals(1, parent.replies.size) + assertEquals(beforeReply.copy(text = "Edited reply."), parent.replies[0]) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `editDocxReply refuses an unknown replyId`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = editDocxReply(target.absolutePath, "docs/launch-brief.docx", "w-1", "w-1-r9", "x", home) + assertEquals(DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND), result) + } + + @Test + fun `deleteDocxComment removes the root, its reply, and BOTH document xml anchors, with no dangling relationships content-types`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val before = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(before is DocxReadResult.Ok && (before as DocxReadResult.Ok).comments.any { it.id == "w-1" }) + + val result = deleteDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-1", home) + assertEquals(DocxWriteResult.Ok(Unit), result) + + val after = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(after is DocxReadResult.Ok, "expected Ok, got $after") + val comments = (after as DocxReadResult.Ok).comments + assertFalse(comments.any { it.id == "w-1" }) // thread gone + assertTrue(comments.any { it.id == "w-0" }) // sibling survives — surgical removal, not a rebuild + + val documentXml = partText(target, "word/document.xml")!! + assertFalse(Regex("w:id=\"1\"").containsMatchIn(documentXml)) // no dangling range/reference markers + + // Every r:id document.xml still references resolves in rels, with a + // matching [Content_Types].xml Override for every comments part left + // standing — re-run here directly as an independent structural pin, + // the same sanity check deleteDocxComment's own verify step already + // ran before reporting success. + val relsXml = partText(target, "word/_rels/document.xml.rels")!! + val relIds = Regex("Id=\"([^\"]+)\"").findAll(relsXml).map { it.groupValues[1] }.toSet() + for (m in Regex("r:id=\"([^\"]+)\"").findAll(documentXml)) { + assertTrue(relIds.contains(m.groupValues[1]), "dangling r:id ${m.groupValues[1]}") + } + val contentTypesXml = partText(target, "[Content_Types].xml")!! + assertTrue(contentTypesXml.contains("/word/comments.xml")) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `deleteDocxComment on a comment with no replies removes only its own range reference, leaving siblings untouched`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = deleteDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-0", home) + assertEquals(DocxWriteResult.Ok(Unit), result) + val after = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(after is DocxReadResult.Ok, "expected Ok, got $after") + val comments = (after as DocxReadResult.Ok).comments + assertFalse(comments.any { it.id == "w-0" }) + assertTrue(comments.any { it.id == "w-1" }) // untouched sibling, replies and all + } + + @Test + fun `deleteDocxComment refuses an id this file does not have, leaving the file byte-identical`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val before = target.readBytes() + val result = deleteDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-999", home) + assertEquals(DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND), result) + assertTrue(target.readBytes().contentEquals(before)) + } + + @Test + fun `deleteDocxReply removes ONE reply, leaving the root comment and its document anchor intact`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val before = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(before is DocxReadResult.Ok, "expected Ok, got $before") + val replyId = (before as DocxReadResult.Ok).comments.find { it.id == "w-1" }?.replies?.get(0)?.id + assertNotNull(replyId) + + val result = deleteDocxReply(target.absolutePath, "docs/launch-brief.docx", "w-1", replyId, home) + assertEquals(DocxWriteResult.Ok(Unit), result) + + val after = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(after is DocxReadResult.Ok, "expected Ok, got $after") + val root = (after as DocxReadResult.Ok).comments.find { it.id == "w-1" } + assertNotNull(root) // the thread itself survives + assertTrue(root.replies.isEmpty()) + + // The root's own document.xml anchor is untouched — a reply never had one. + val documentXml = partText(target, "word/document.xml")!! + assertEquals(1, Regex("w:commentRangeStart w:id=\"1\"").findAll(documentXml).count()) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `deleteDocxReply refuses an unknown replyId`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = deleteDocxReply(target.absolutePath, "docs/launch-brief.docx", "w-1", "w-1-r9", home) + assertEquals(DocxWriteResult.Err(DocxWriteError.COMMENT_NOT_FOUND), result) + } + + // ── step 0: refuses a file open elsewhere, before backup (T17 follow-up, + // design §3.3's new step 0, design review round 3 F4) ─────────────────── + + @Test + fun `refuses file-open-elsewhere before touching the backup or the target when a Word owner file sits beside it`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val ownerFile = File(target.parentFile, "~\$${target.name}") + ownerFile.writeText("destin") + val before = target.readBytes() + val backupPath = docxBackupPathFor(home, target.absolutePath) + + val result = replyToDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-1", "x", "user", home) + assertEquals(DocxWriteResult.Err(DocxWriteError.FILE_OPEN_ELSEWHERE), result) + assertTrue(target.readBytes().contentEquals(before)) // the target itself is untouched + assertFalse(backupPath.exists()) // never even reached step 1 (backup) + } + + @Test + fun `refuses file-open-elsewhere when a LibreOffice lock file sits beside the target`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val lockFile = File(target.parentFile, ".~lock.${target.name}#") + lockFile.writeText(",destin,localhost,01-01-2026 00:00,file:///home/destin;") + + val result = resolveDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-0", home) + assertEquals(DocxWriteResult.Err(DocxWriteError.FILE_OPEN_ELSEWHERE), result) + } + + @Test + fun `proceeds normally once the owner file is gone`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val ownerFile = File(target.parentFile, "~\$${target.name}") + ownerFile.writeText("destin") + + val first = resolveDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-0", home) + assertEquals(DocxWriteResult.Err(DocxWriteError.FILE_OPEN_ELSEWHERE), first) + + ownerFile.delete() + val retry = resolveDocxComment(target.absolutePath, "docs/launch-brief.docx", "w-0", home) + assertEquals(DocxWriteResult.Ok(Unit), retry) + } + + // ── verify-before-replace, and the backup's own lifecycle ─────────── + // + // T17's own algorithm verifies against a SCRATCH file before the real + // target is EVER touched (see DocxComments.kt's own header for why this + // is a stronger, simpler guarantee than desktop's write-then-verify- + // then-restore shape) — a failed verification therefore means the target + // was NEVER written and the backup was NEVER created (there being nothing + // yet worth protecting), rather than desktop's own "the backup existed + // then got consumed/renamed back" lifecycle. Both shapes deliver the + // identical outward contract §3.3 step 6 requires: the file the caller + // has open is always either the successfully-mutated version or + // byte-identical to what it was before. + + @Test + fun `a failed verification never touches the target and never creates a backup`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val before = target.readBytes() + val backupPath = docxBackupPathFor(home, target.absolutePath) + assertFalse(backupPath.exists()) + + val result = writeDocxMutation( + target.absolutePath, + home, + mutate = { workCopy, outFile -> workCopy.copyTo(outFile, overwrite = true); DocxWriteResult.Ok(Unit) }, + verify = { _, _, _ -> false }, // the injected fault: verification always fails + ) + assertEquals(DocxWriteResult.Err(DocxWriteError.VERIFY_FAILED), result) + assertTrue(target.readBytes().contentEquals(before)) // untouched + assertFalse(backupPath.exists()) // never created — nothing was ever written + } + + @Test + fun `the backup exists while verify runs, and is kept after a successful write`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val backupPath = docxBackupPathFor(home, target.absolutePath) + val before = target.readBytes() + var sawBackupDuringVerify = true // becomes false only if the assertion below actually checked + val result = writeDocxMutation( + target.absolutePath, + home, + mutate = { workCopy, outFile -> workCopy.copyTo(outFile, overwrite = true); DocxWriteResult.Ok(Unit) }, + verify = { _, _, _ -> + // Verify runs BEFORE the backup is written in this pipeline's + // own order — confirming that ordering directly rather than + // assuming it, since it differs from desktop's own sequence. + sawBackupDuringVerify = backupPath.exists() + true + }, + ) + assertEquals(DocxWriteResult.Ok(Unit), result) + assertFalse(sawBackupDuringVerify) // did NOT exist yet during verify — this pipeline backs up AFTER verify, before replace + assertTrue(backupPath.exists()) // KEPT after success + assertTrue(backupPath.readBytes().contentEquals(before)) // holds the PRE-write bytes + } + + @Test + fun `one rolling backup per file - a second successful write overwrites the same backup path`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val backupPathFirst = docxBackupPathFor(home, target.absolutePath) + val firstOriginal = target.readBytes() + writeDocxMutation( + target.absolutePath, home, + mutate = { workCopy, outFile -> workCopy.copyTo(outFile, overwrite = true); DocxWriteResult.Ok(Unit) }, + verify = { _, _, _ -> true }, + ) + assertTrue(backupPathFirst.readBytes().contentEquals(firstOriginal)) + + val secondOriginal = target.readBytes() + val result = writeDocxMutation( + target.absolutePath, home, + mutate = { workCopy, outFile -> + val bytes = workCopy.readBytes() + outFile.writeBytes(bytes + byteArrayOf(0)) + DocxWriteResult.Ok(Unit) + }, + verify = { _, _, _ -> true }, + ) + assertEquals(DocxWriteResult.Ok(Unit), result) + val backupPathSecond = docxBackupPathFor(home, target.absolutePath) + assertEquals(backupPathFirst, backupPathSecond) // same stable path, both times + assertTrue(backupPathSecond.readBytes().contentEquals(secondOriginal)) // rolled forward, not accumulated + } + + // ── F1 (T17 review, major/durability) — fsync before rename ──────────── + + @Test + fun `a failing fsync leaves the target byte-identical and never creates a backup`() = runTest { + // Proves ORDERING ("the sync call happens before the rename") the + // only way a JVM unit test can: fsync's real effect (durability + // across a crash) is invisible to a passing process, so this injects + // a FAILING fsync and confirms neither the rename nor the backup + // — both of which the real pipeline runs AFTER a successful sync — + // ever happened. + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val before = target.readBytes() + val backupPath = docxBackupPathFor(home, target.absolutePath) + val result = writeDocxMutation( + target.absolutePath, + home, + mutate = { workCopy, outFile -> workCopy.copyTo(outFile, overwrite = true); DocxWriteResult.Ok(Unit) }, + verify = { _, _, _ -> true }, + syncFile = { throw java.io.IOException("simulated fsync failure") }, + ) + assertEquals(DocxWriteResult.Err(DocxWriteError.WRITE_FAILED), result) + assertTrue(target.readBytes().contentEquals(before)) // rename never ran + assertFalse(backupPath.exists()) // backup never ran either — sync runs before both + } + + @Test + fun `a successful write still calls the real fsync (default parameter wired up)`() = runTest { + // The seam's DEFAULT argument is the real `FileChannel.force(true)` + // fsync (`defaultFsyncFile`) — this test calls the public + // add/reply/etc. entry points (which never pass their own + // `syncFile`) end to end, so a regression that silently drops the + // default wiring (e.g. an accidental `syncFile = {}` no-op default) + // would still leave every OTHER test in this file green. A real + // fsync against a real file never throws under normal conditions, so + // asserting the write still succeeds is the correct positive pin. + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = addDocxComment(target.absolutePath, "docs/launch-brief.docx", textSelector("30%"), "x", "user", home) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + } + + // ── concurrency ────────────────────────────────────────────────────── + + @Test + fun `two mutations on one file serialize, and neither is lost`() = runTest { + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val jobA = async { + addDocxComment( + target.absolutePath, "docs/launch-brief.docx", + textSelector("Marketing emails go out the same morning as the public launch."), + "First concurrent comment.", "user", home, + ) + } + val jobB = async { + addDocxComment( + target.absolutePath, "docs/launch-brief.docx", + textSelector("The payment screen has not been tested on older Android phones."), + "Second concurrent comment.", "assistant", home, + ) + } + val a = jobA.await() + val b = jobB.await() + assertTrue(a is DocxWriteResult.Ok, "expected Ok, got $a") + assertTrue(b is DocxWriteResult.Ok, "expected Ok, got $b") + assertNotEquals((a as DocxWriteResult.Ok).value, (b as DocxWriteResult.Ok).value) // unique ids even when minted concurrently + + val read = readDocxComments(target, "docs/launch-brief.docx") + assertTrue(read is DocxReadResult.Ok, "expected Ok, got $read") + val comments = (read as DocxReadResult.Ok).comments + // Original 2 top-level comments + both concurrent adds — NEITHER lost. + assertEquals(4, comments.size) + assertTrue(comments.any { it.text == "First concurrent comment." }) + assertTrue(comments.any { it.text == "Second concurrent comment." }) + + assertPartsWellFormed(target) // F5 + } + + // ── OOXML wiring sanity (F17) ──────────────────────────────────────── + + @Test + fun `every r id the new run references resolves in document xml rels, and has a content-types override`() = runTest { + val target = scratchCopy("no-comments.docx") + val home = scratchHomeDir() + val result = addDocxComment(target.absolutePath, "docs/no-comments.docx", textSelector("Nothing here has ever been commented on."), "x", "user", home) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + + val relIds = Regex("Relationship\\b[^>]*\\bId=\"([^\"]+)\"").findAll(partText(target, "word/_rels/document.xml.rels")!!) + .map { it.groupValues[1] }.toSet() + val referenced = Regex("r:id=\"([^\"]*)\"").findAll(partText(target, "word/document.xml")!!).map { it.groupValues[1] }.toList() + for (id in referenced) assertTrue(relIds.contains(id), "dangling r:id $id") + + val overrides = Regex("PartName=\"([^\"]+)\"").findAll(partText(target, "[Content_Types].xml")!!).map { it.groupValues[1] }.toSet() + assertTrue(overrides.contains("/word/comments.xml")) + + assertPartsWellFormed(target) // F5 + } + + @Test + fun `runs in a plain JVM environment, no Android framework class involved`() = runTest { + // Same pin as DocxCommentsTest.kt's own — this is the whole point of + // "a JVM unit test, not an instrumented/on-device test." + val target = scratchCopy("launch-brief.docx") + val home = scratchHomeDir() + val result = addDocxComment(target.absolutePath, "docs/launch-brief.docx", textSelector("30%"), "x", "user", home) + assertTrue(result is DocxWriteResult.Ok) + } +} + +// ── 2026-09-28 PR review fixes (mirrors desktop's docx-comments.test.ts) ── + +/** A one-paragraph docx whose body is exactly `paragraphInner`, like + * desktop's `buildDocxWithParagraph`. `commentsRootAttrs` lets a test give + * comments.xml a root WITHOUT `xmlns:w14`, as Word 2007/LibreOffice write it. */ +private fun buildDocxWithParagraph(paragraphInner: String, commentsRootAttrs: String = "$W_NS_ATTR $W14_NS_ATTR", fileName: String = "built.docx"): File { + val dir = Files.createTempDirectory("ycd-docx-review-").toFile() + dir.deleteOnExit() + val target = File(dir, fileName) + java.util.zip.ZipOutputStream(target.outputStream()).use { zos -> + fun entry(name: String, content: String) { + zos.putNextEntry(java.util.zip.ZipEntry(name)) + zos.write(content.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + entry( + "[Content_Types].xml", + """""", + ) + entry( + "_rels/.rels", + """""", + ) + entry( + "word/_rels/document.xml.rels", + """""", + ) + entry("word/document.xml", """$paragraphInner""") + entry("word/comments.xml", """""") + } + return target +} + +private val TOKEN_RE = Regex("""]*>([^<]*)| = + TOKEN_RE.findAll(partText(file, "word/document.xml")!!).map { m -> + if (m.groups[1] != null) "\"${m.groupValues[1]}\"" else m.groupValues[2] + }.toList() + +class DocxCommentsReviewFixesTest { + @Test + fun `a comment inside text that follows a tab in the same run keeps the tab`() = runTest { + val target = buildDocxWithParagraph("Alpha beta") + val result = addDocxComment(target.absolutePath, "built.docx", textSelector("lph"), "x", "user", scratchHomeDir()) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + assertEquals( + listOf("tab", "\"A\"", "commentRangeStart", "\"lph\"", "commentRangeEnd", "commentReference", "\"a beta\""), + paragraphTokens(target), + ) + assertPartsWellFormed(target) + } + + @Test + fun `a quote spanning a tab inside ONE run succeeds and keeps text, tab and order intact`() = runTest { + val target = buildDocxWithParagraph("HelloWorld") + val result = addDocxComment(target.absolutePath, "built.docx", textSelector("llo\tWo"), "x", "user", scratchHomeDir()) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + assertEquals( + listOf("\"He\"", "commentRangeStart", "\"llo\"", "tab", "\"Wo\"", "commentRangeEnd", "commentReference", "\"rld\""), + paragraphTokens(target), + ) + val read = readDocxComments(target, "built.docx") as DocxReadResult.Ok + assertEquals(listOf("llo\tWo"), read.comments.map { (it.selector as CommentSelector.Text).selector.exact }) + } + + @Test + fun `a line break and an inline image in the split run both survive`() = runTest { + val target = buildDocxWithParagraph("BeforeAfter text") + val result = addDocxComment(target.absolutePath, "built.docx", textSelector("ter te"), "x", "user", scratchHomeDir()) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + assertEquals( + listOf("\"Before\"", "br", "drawing", "\"Af\"", "commentRangeStart", "\"ter te\"", "commentRangeEnd", "commentReference", "\"xt\""), + paragraphTokens(target), + ) + } + + @Test + fun `a tab-stop definition in the paragraph properties is not counted as a tab character`() = runTest { + val target = buildDocxWithParagraph("""Plain words""") + val result = addDocxComment(target.absolutePath, "built.docx", textSelector("Plain"), "x", "user", scratchHomeDir()) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + assertTrue(Regex("""""").containsMatchIn(partText(target, "word/document.xml")!!)) + } + + @Test + fun `declares xmlns w14 on a comments xml that never had it before writing w14 paraId`() = runTest { + val target = buildDocxWithParagraph("Some text here", commentsRootAttrs = W_NS_ATTR) + val result = addDocxComment(target.absolutePath, "built.docx", textSelector("text"), "x", "user", scratchHomeDir()) + assertTrue(result is DocxWriteResult.Ok, "expected Ok, got $result") + val commentsXml = partText(target, "word/comments.xml")!! + assertTrue(Regex("""]*xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"""").containsMatchIn(commentsXml), commentsXml) + assertTrue(undeclaredPrefixes(commentsXml).isEmpty()) + } + + @Test + fun `the strict prefix check flags an undeclared prefix and ignores comment text`() { + assertEquals(setOf("w14"), undeclaredPrefixes("""ratio:1=2""")) + assertTrue(undeclaredPrefixes("""""").isEmpty()) + } + + // Word shortens its owner file's name: 8+ characters before the extension + // drop the first two, exactly 7 drop the first one. + @Test + fun `refuses long Word names while Word's shortened owner file sits beside them`() = runTest { + for ((name, owner) in listOf("Report2024.docx" to "~\$port2024.docx", "Minutes.docx" to "~\$nutes.docx", "Budget.docx" to "~\$Budget.docx")) { + val target = buildDocxWithParagraph("Some text here", fileName = name) + File(target.parentFile, owner).writeText("destin") + val before = target.readBytes() + val result = addDocxComment(target.absolutePath, name, textSelector("text"), "x", "user", scratchHomeDir()) + assertEquals(DocxWriteResult.Err(DocxWriteError.FILE_OPEN_ELSEWHERE), result, name) + assertTrue(target.readBytes().contentEquals(before), name) + } + } +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/OversizedZipTestHelper.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/OversizedZipTestHelper.kt new file mode 100644 index 000000000..4be317373 --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/OversizedZipTestHelper.kt @@ -0,0 +1,145 @@ +// Test-only helper for pinning the decompression-bomb guard +// (DocCommentsZipSizeGuard.kt), Kotlin port of desktop's own test helper +// `desktop/tests/fixtures/doc-comments/oversized-zip.ts` — see that file's +// header for the full reasoning; ported here rather than shared, since the +// two runtimes have no shared test-support module. +// +// Builds a REAL, tiny, valid zip archive and then patches one entry's +// CENTRAL DIRECTORY record to declare a huge uncompressed size — a +// "decompression bomb" shape — without changing its actual (tiny) compressed +// bytes. This never allocates or compresses anything close to the fake +// declared size: only a 4-byte integer field in the archive's own metadata +// is overwritten. `java.util.zip.ZipFile` (like JSZip's `loadAsync`) reads an +// entry's declared uncompressed size from exactly this central-directory +// field, never the local file header's own copy of it, so patching only the +// central directory is sufficient to fool the same central-directory-based +// check `DocCommentsZipSizeGuard` performs. +package com.youcoded.app.doccomments + +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +private const val CENTRAL_DIR_SIGNATURE = 0x02014b50L + +private fun readUInt32LE(b: ByteArray, off: Int): Long = + (b[off].toLong() and 0xFF) or + ((b[off + 1].toLong() and 0xFF) shl 8) or + ((b[off + 2].toLong() and 0xFF) shl 16) or + ((b[off + 3].toLong() and 0xFF) shl 24) + +private fun readUInt16LE(b: ByteArray, off: Int): Int = + (b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8) + +private fun writeUInt32LE(b: ByteArray, off: Int, value: Long) { + b[off] = (value and 0xFF).toByte() + b[off + 1] = ((value shr 8) and 0xFF).toByte() + b[off + 2] = ((value shr 16) and 0xFF).toByte() + b[off + 3] = ((value shr 24) and 0xFF).toByte() +} + +/** + * Builds a zip containing `files` (name -> text content), then rewrites + * `targetEntryName`'s declared uncompressed size (in the zip's central + * directory only) to `fakeUncompressedSize` — a number far larger than the + * entry's real, tiny content. + */ +fun buildDeclaredOversizeZip(files: Map, targetEntryName: String, fakeUncompressedSize: Long): ByteArray { + val baos = ByteArrayOutputStream() + ZipOutputStream(baos).use { zos -> + for ((name, content) in files) { + zos.putNextEntry(ZipEntry(name)) + zos.write(content.toByteArray(StandardCharsets.UTF_8)) + zos.closeEntry() + } + } + return patchCentralDirectoryUncompressedSize(baos.toByteArray(), targetEntryName, fakeUncompressedSize) +} + +private fun patchCentralDirectoryUncompressedSize(buf: ByteArray, targetEntryName: String, fakeSize: Long): ByteArray { + val patched = buf.copyOf() + val targetNameBytes = targetEntryName.toByteArray(StandardCharsets.UTF_8) + var offset = 0 + var found = false + + // Central directory file header layout (APPNOTE.TXT §4.3.12): + // 0 sig(4) 4 versionMadeBy(2) 6 versionNeeded(2) 8 bitFlag(2) + // 10 compressionMethod(2) 12 modTime(2) 14 modDate(2) 16 crc32(4) + // 20 compressedSize(4) 24 uncompressedSize(4) 28 fileNameLength(2) + // 30 extraFieldLength(2) 32 fileCommentLength(2) ... 46 fileName + while (offset <= patched.size - 46) { + if (readUInt32LE(patched, offset) != CENTRAL_DIR_SIGNATURE) { + offset++ + continue + } + val fileNameLength = readUInt16LE(patched, offset + 28) + val extraFieldLength = readUInt16LE(patched, offset + 30) + val fileCommentLength = readUInt16LE(patched, offset + 32) + val nameStart = offset + 46 + val name = patched.copyOfRange(nameStart, nameStart + fileNameLength) + if (name.contentEquals(targetNameBytes)) { + writeUInt32LE(patched, offset + 24, fakeSize) + found = true + } + offset = nameStart + fileNameLength + extraFieldLength + fileCommentLength + } + + check(found) { "buildDeclaredOversizeZip: no central directory entry found for \"$targetEntryName\"" } + return patched +} + +/** Builds a plain, ordinary real zip from `files` (name -> text content) — + * no size tampering at all. A small shared helper so a test pinning + * ordinary/malformed-content behaviour (F1's DOCTYPE refusal, for example) + * doesn't need to hand-roll `ZipOutputStream` boilerplate of its own. */ +fun buildPlainZip(files: Map): ByteArray { + val baos = ByteArrayOutputStream() + ZipOutputStream(baos).use { zos -> + for ((name, content) in files) { + zos.putNextEntry(ZipEntry(name)) + zos.write(content.toByteArray(StandardCharsets.UTF_8)) + zos.closeEntry() + } + } + return baos.toByteArray() +} + +/** + * Builds a REAL zip bomb: `bombEntryName` genuinely decompresses to + * `realUncompressedBytes` (all zero bytes — DEFLATE compresses that to almost + * nothing, so this stays a cheap, fast fixture despite the large real size), + * plus any other plain-text `files`. The bomb entry's DECLARED uncompressed + * size (central directory only, same mechanism `buildDeclaredOversizeZip` + * above relies on) is then patched down to `declaredUncompressedSize` — far + * SMALLER than its real content — so a declared-size-only precheck + * (`checkNamedEntriesWithinCeiling`/`checkAllEntriesWithinCeiling`) would wave + * it straight through. Used to pin F3 (implementation review): `readEntryBounded`'s + * own byte-COUNTING guard, which never trusts the declared size at all and + * must catch this shape on its own, independent of the declared-size checks. + */ +fun buildRealZipBomb( + files: Map, + bombEntryName: String, + realUncompressedBytes: Long, + declaredUncompressedSize: Long, +): ByteArray { + val baos = ByteArrayOutputStream() + ZipOutputStream(baos).use { zos -> + for ((name, content) in files) { + zos.putNextEntry(ZipEntry(name)) + zos.write(content.toByteArray(StandardCharsets.UTF_8)) + zos.closeEntry() + } + zos.putNextEntry(ZipEntry(bombEntryName)) + val chunk = ByteArray(8192) // all-zero — trivial for DEFLATE to compress + var written = 0L + while (written < realUncompressedBytes) { + val n = minOf(chunk.size.toLong(), realUncompressedBytes - written).toInt() + zos.write(chunk, 0, n) + written += n + } + zos.closeEntry() + } + return patchCentralDirectoryUncompressedSize(baos.toByteArray(), bombEntryName, declaredUncompressedSize) +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/XlsxCommentsCrossPlatformParityTest.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/XlsxCommentsCrossPlatformParityTest.kt new file mode 100644 index 000000000..19a390e7e --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/XlsxCommentsCrossPlatformParityTest.kt @@ -0,0 +1,514 @@ +// Cross-platform WRITE parity for T21 of the doc-comments build (docs/ +// active/specs/2026-09-26-doc-comments-build-design.md §4.3/§4.3a, §8 T21, +// §9.3). Xlsx sibling of `DocxCommentsCrossPlatformParityTest.kt` (see that +// file's own header for the shared rationale) — proves the Kotlin writer and +// the REAL desktop writer (`desktop/src/main/doc-comments/xlsx-comments.ts`) +// agree on the SAME operation(s) against the SAME real-file input, not just +// that each independently passes its own suite. +// +// Fixtures: app/src/test/resources/doc-comments/write-golden/{add-docling, +// move-docling,resolve-b19-sibling,elden-sequence}.{xlsx,json} — the +// desktop writer's OWN real output, generated by the committed script +// `desktop/tests/fixtures/doc-comments/generate-xlsx-write-golden.mjs` +// (copied verbatim into this module's test resources), never hand- +// transcribed. +// +// WHY this does NOT do a docx-style "every untouched PART is byte-identical" +// check: xlsx's own per-sheet threadedComment{N}.xml/comments{N}.xml/ +// vmlDrawing{N}.vml numbering is dynamic (assigned by "next available N", +// per shared-fixtures/doc-comments/xlsx-threaded-reference/manifest.json's +// own `partPaths.numberingScheme` note) and a comment on a sheet that +// previously had NONE (this test's own `elden-sequence` case, deliberately +// targeting `Sorceries & Incantations List` — confirmed comment-free in that +// same manifest) creates brand-new parts whose exact names are an +// IMPLEMENTATION DETAIL neither writer's own contract fixes. The guarantee +// this design actually needs — "an unrelated existing thread is never +// disturbed" (§9.3, §4.2's own multi-thread-per-cell risk) — is instead +// proven MORE DIRECTLY and MORE STRONGLY here: reading back the WHOLE +// ~700-thread comment set from both Kotlin's own output and desktop's +// committed golden output and asserting every thread OTHER than the one this +// operation touches is byte-for-byte identical in its OWN read-back shape +// (id, text, author, resolved, replies, history) — a corrupted or dropped +// unrelated part would show up here regardless of which physical XML part it +// lived in. +// +// WHY the touched thread's own `id`/GUID is excluded from the cross-writer +// comparison: `mutateAddXlsxComment` mints a fresh random GUID per call +// (§4.2's `guidFormat`) — two INDEPENDENT writers applied to the same input +// never produce the same GUID for a BRAND-NEW thread, which is expected, not +// drift. A `resolve`/`move` against a PRE-EXISTING thread (`resolve-b19- +// sibling`, `move-docling`) keeps that thread's own real, content-derived +// GUID unchanged, so those two cases DO compare the id for exact equality. +// +// WHY `createdAt` is excluded universally (mirroring the docx parity test's +// own identical exclusion): a `reply`/`add` step stamps real wall-clock time +// (`xlsx-comments.ts`'s `formatThreadedDate(new Date())`) — two independent +// writer runs, run at two different real moments, can never agree on it. +package com.youcoded.app.doccomments + +import org.json.JSONObject +import org.junit.Test +import java.io.File +import java.nio.file.Files +import java.util.zip.ZipFile +import kotlinx.coroutines.runBlocking +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +private fun xlsxParityResourceBytes(path: String): ByteArray { + val stream = object {}.javaClass.getResourceAsStream("/doc-comments/$path") ?: fail("missing test resource doc-comments/$path") + return stream.use { it.readBytes() } +} + +private fun xlsxParityScratchCopy(bytes: ByteArray, name: String): File { + val dir = Files.createTempDirectory("ycd-xlsx-parity-").toFile() + dir.deleteOnExit() + val target = File(dir, name) + target.writeBytes(bytes) + return target +} + +private fun xlsxParityScratchHome(): File { + val dir = Files.createTempDirectory("ycd-xlsx-parity-home-").toFile() + dir.deleteOnExit() + return dir +} + +private fun xlsxParitySelector(cell: String, sheet: String?): CommentSelector = CommentSelector.Cell(CellSelector(cell, sheet)) + +/** File-private zip introspection for the delete-last-comment-fresh + * structural cleanup check — DocxCommentsCrossPlatformParityTest.kt's own + * `entryNames`/`entryBytes` are file-private in Kotlin, so this file needs + * its own copies rather than sharing them. */ +private fun xlsxParityEntryNames(file: File): Set { + ZipFile(file).use { zip -> return zip.entries().asSequence().filter { !it.isDirectory }.map { it.name }.toSet() } +} + +private fun xlsxParityEntryBytes(file: File, name: String): ByteArray? { + ZipFile(file).use { zip -> + val entry = zip.getEntry(name) ?: return null + return zip.getInputStream(entry).use { it.readBytes() } + } +} + +private fun xlsxParityReadOk(file: File, path: String): List { + val r = readXlsxComments(file, path) + assertTrue(r is XlsxReadResult.Ok, "expected Ok reading $path, got $r") + return (r as XlsxReadResult.Ok).comments +} + +private fun xlsxParityFindByCell(comments: List, cell: String, sheet: String? = null): PersistedComment { + val match = comments.filter { c -> + val sel = c.selector + sel is CommentSelector.Cell && sel.selector.cell == cell && (sheet == null || sel.selector.sheet == sheet) + } + assertEquals(1, match.size, "expected exactly one thread at $cell${if (sheet != null) "/$sheet" else ""}, found ${match.size}") + return match[0] +} + +/** Field-for-field equality EXCLUDING `id` and `createdAt` — see this file's + * own header for why both are expected to differ between two independent + * writer runs for a newly-minted or freshly-timestamped thread. */ +private fun assertSameThreadContent(expected: PersistedComment, actual: PersistedComment, label: String) { + assertEquals(expected.selector, actual.selector, "$label: selector") + assertEquals(expected.text, actual.text, "$label: text") + assertEquals(expected.author, actual.author, "$label: author") + assertEquals(expected.resolved, actual.resolved, "$label: resolved") + assertEquals(expected.replies.size, actual.replies.size, "$label: replies.size") + for (i in expected.replies.indices) { + assertEquals(expected.replies[i].author, actual.replies[i].author, "$label: replies[$i].author") + assertEquals(expected.replies[i].text, actual.replies[i].text, "$label: replies[$i].text") + } + assertEquals(expected.history.size, actual.history.size, "$label: history.size") + for (i in expected.history.indices) { + assertEquals(expected.history[i].by, actual.history[i].by, "$label: history[$i].by") + assertEquals(expected.history[i].action, actual.history[i].action, "$label: history[$i].action") + } +} + +/** Same as `assertSameThreadContent`, plus `id` — for a thread this + * operation never re-mints (a pre-existing, content-derived GUID). */ +private fun assertSameThreadContentAndId(expected: PersistedComment, actual: PersistedComment, label: String) { + assertEquals(expected.id, actual.id, "$label: id") + assertSameThreadContent(expected, actual, label) +} + +/** Proves every thread OTHER than `excludeIds` is byte-for-byte identical + * (including id, excluding createdAt) between Kotlin's own output and + * desktop's committed golden output — the direct "an unrelated thread is + * never disturbed" guarantee this file's own header explains. */ +private fun assertUnrelatedThreadsUntouched(desktopComments: List, kotlinComments: List, excludeIds: Set, label: String) { + val desktopRest = desktopComments.filter { it.id !in excludeIds }.sortedBy { it.id } + val kotlinRest = kotlinComments.filter { it.id !in excludeIds }.sortedBy { it.id } + assertEquals(desktopRest.size, kotlinRest.size, "$label: unrelated-thread count differs") + for (i in desktopRest.indices) { + assertSameThreadContentAndId(desktopRest[i], kotlinRest[i], "$label unrelated[$i]") + } +} + +class XlsxCommentsCrossPlatformParityTest { + + // ── add-docling: a brand-new thread on the real Excel-authored file ──── + @Test + fun addDocling() { + val json = JSONObject(String(xlsxParityResourceBytes("write-golden/add-docling.json"), Charsets.UTF_8)) + val fixture = json.getString("fixture") + val path = json.getString("path") + val step = json.getJSONArray("steps").getJSONObject(0) + val args = step.getJSONObject("args") + + val originalBytes = xlsxParityResourceBytes(fixture) + val kotlinTarget = xlsxParityScratchCopy(originalBytes, fixture) + val home = xlsxParityScratchHome() + + val result = runBlocking { + addXlsxComment(kotlinTarget.absolutePath, path, xlsxParitySelector(args.getString("cell"), null), args.getString("text"), args.getString("author"), home) + } + assertTrue(result is XlsxWriteResult.Ok, "Kotlin add failed: $result") + + val desktopGolden = xlsxParityScratchCopy(xlsxParityResourceBytes("write-golden/add-docling.xlsx"), "desktop-add-docling.xlsx") + val desktopComments = xlsxParityReadOk(desktopGolden, path) + val kotlinComments = xlsxParityReadOk(kotlinTarget, path) + + val desktopNew = xlsxParityFindByCell(desktopComments, args.getString("cell")) + val kotlinNew = xlsxParityFindByCell(kotlinComments, args.getString("cell")) + assertSameThreadContent(desktopNew, kotlinNew, "addDocling new thread") + assertUnrelatedThreadsUntouched(desktopComments, kotlinComments, setOf(desktopNew.id, kotlinNew.id), "addDocling") + } + + // ── move-docling: relocate the real, pre-existing F7 thread ──────────── + @Test + fun moveDocling() { + val json = JSONObject(String(xlsxParityResourceBytes("write-golden/move-docling.json"), Charsets.UTF_8)) + val fixture = json.getString("fixture") + val path = json.getString("path") + val step = json.getJSONArray("steps").getJSONObject(0) + val args = step.getJSONObject("args") + + val originalBytes = xlsxParityResourceBytes(fixture) + val kotlinTarget = xlsxParityScratchCopy(originalBytes, fixture) + val home = xlsxParityScratchHome() + + val result = runBlocking { + moveXlsxComment(kotlinTarget.absolutePath, path, args.getString("id"), xlsxParitySelector(args.getString("newCell"), null), home) + } + assertTrue(result is XlsxWriteResult.Ok, "Kotlin move failed: $result") + + val desktopGolden = xlsxParityScratchCopy(xlsxParityResourceBytes("write-golden/move-docling.xlsx"), "desktop-move-docling.xlsx") + val desktopComments = xlsxParityReadOk(desktopGolden, path) + val kotlinComments = xlsxParityReadOk(kotlinTarget, path) + + // F7's own GUID survives a move unchanged (§4.2/§4.3: "never mints + // fresh GUIDs on a move") — locate the moved thread by its NEW cell + // on both sides, then confirm both sides agree it's still the SAME + // original GUID (the suffix after the cell component). + val desktopMoved = xlsxParityFindByCell(desktopComments, args.getString("newCell")) + val kotlinMoved = xlsxParityFindByCell(kotlinComments, args.getString("newCell")) + assertSameThreadContent(desktopMoved, kotlinMoved, "moveDocling moved thread") + // id shape is `xt-{sheetId}-{cell}-{GUID}`, and a GUID itself + // contains dashes — split with a limit of 4 so element [3] is the + // WHOLE GUID, not just the text after its own last dash. + val originalGuid = args.getString("id").split("-", limit = 4)[3] + assertTrue(desktopMoved.id.endsWith(originalGuid), "desktop's moved id should keep the original GUID unchanged (§4.2/§4.3: never mints fresh GUIDs on a move)") + assertTrue(kotlinMoved.id.endsWith(originalGuid), "Kotlin's moved id should keep the original GUID unchanged") + assertUnrelatedThreadsUntouched(desktopComments, kotlinComments, setOf(desktopMoved.id, kotlinMoved.id), "moveDocling") + } + + // ── resolve-b19-sibling: resolve ONE of elden's real 5 independent + // threads on B19 — the other four (and every other thread in the whole + // ~700-thread workbook) must be completely untouched. ── + @Test + fun resolveB19Sibling() { + val json = JSONObject(String(xlsxParityResourceBytes("write-golden/resolve-b19-sibling.json"), Charsets.UTF_8)) + val fixture = json.getString("fixture") + val path = json.getString("path") + val step = json.getJSONArray("steps").getJSONObject(0) + val args = step.getJSONObject("args") + val targetId = args.getString("id") + + val originalBytes = xlsxParityResourceBytes(fixture) + val kotlinTarget = xlsxParityScratchCopy(originalBytes, fixture) + val home = xlsxParityScratchHome() + + val before = xlsxParityReadOk(kotlinTarget, path) + val b19Before = before.filter { c -> val s = c.selector; s is CommentSelector.Cell && s.selector.cell == "B19" } + assertEquals(5, b19Before.size, "elden's real B19 cell carries 5 independent threads") + + val result = runBlocking { resolveXlsxComment(kotlinTarget.absolutePath, path, targetId, home) } + assertTrue(result is XlsxWriteResult.Ok, "Kotlin resolve failed: $result") + + val desktopGolden = xlsxParityScratchCopy(xlsxParityResourceBytes("write-golden/resolve-b19-sibling.xlsx"), "desktop-resolve-b19-sibling.xlsx") + val desktopComments = xlsxParityReadOk(desktopGolden, path) + val kotlinComments = xlsxParityReadOk(kotlinTarget, path) + + // Same pre-existing GUID both sides (never re-minted by resolve) — + // exact id equality is meaningful here, unlike the `add`-originated + // cases above. + val desktopTarget = desktopComments.find { it.id == targetId } ?: fail("target $targetId missing from desktop golden") + val kotlinTarget2 = kotlinComments.find { it.id == targetId } ?: fail("target $targetId missing from Kotlin output") + assertSameThreadContentAndId(desktopTarget, kotlinTarget2, "resolveB19Sibling target") + assertTrue(kotlinTarget2.resolved, "the resolved thread should read back resolved") + + // The other FOUR B19 threads (and the ~700 other threads workbook- + // wide) are byte-for-byte untouched on BOTH sides. + assertUnrelatedThreadsUntouched(desktopComments, kotlinComments, setOf(targetId), "resolveB19Sibling") + val b19After = kotlinComments.filter { c -> val s = c.selector; s is CommentSelector.Cell && s.selector.cell == "B19" } + assertEquals(5, b19After.size, "resolving one B19 thread must not drop or merge its siblings") + } + + // ── elden-sequence: add (fresh sheet, no prior comment parts) -> reply + // -> resolve -> reopen -> move CROSS-SHEET (onto a sheet that already + // has other threads) — the literal "add+reply+resolve+move sequence" + // design review 1/3 (F11/F2) names, run end to end with the Kotlin + // writer against the SAME real elden workbook desktop wrote it against. ── + @Test + fun eldenSequence() { + val json = JSONObject(String(xlsxParityResourceBytes("write-golden/elden-sequence.json"), Charsets.UTF_8)) + val fixture = json.getString("fixture") + val path = json.getString("path") + val steps = json.getJSONArray("steps") + + val originalBytes = xlsxParityResourceBytes(fixture) + val kotlinTarget = xlsxParityScratchCopy(originalBytes, fixture) + val home = xlsxParityScratchHome() + + var currentId: String? = null + for (i in 0 until steps.length()) { + val step = steps.getJSONObject(i) + val op = step.getString("op") + val args = step.getJSONObject("args") + when (op) { + "add" -> { + val r = runBlocking { + addXlsxComment(kotlinTarget.absolutePath, path, xlsxParitySelector(args.getString("cell"), (if (args.has("sheet")) args.getString("sheet") else null)), args.getString("text"), args.getString("author"), home) + } + assertTrue(r is XlsxWriteResult.Ok, "eldenSequence step $i (add) failed: $r") + currentId = (r as XlsxWriteResult.Ok).value + } + "reply" -> { + val id = currentId ?: fail("eldenSequence step $i (reply) has no current thread id") + val r = runBlocking { replyToXlsxComment(kotlinTarget.absolutePath, path, id, args.getString("text"), args.getString("author"), home) } + assertTrue(r is XlsxWriteResult.Ok, "eldenSequence step $i (reply) failed: $r") + } + "resolve" -> { + val id = currentId ?: fail("eldenSequence step $i (resolve) has no current thread id") + val r = runBlocking { resolveXlsxComment(kotlinTarget.absolutePath, path, id, home) } + assertTrue(r is XlsxWriteResult.Ok, "eldenSequence step $i (resolve) failed: $r") + } + "reopen" -> { + val id = currentId ?: fail("eldenSequence step $i (reopen) has no current thread id") + val r = runBlocking { reopenXlsxComment(kotlinTarget.absolutePath, path, id, home) } + assertTrue(r is XlsxWriteResult.Ok, "eldenSequence step $i (reopen) failed: $r") + } + "move" -> { + val id = currentId ?: fail("eldenSequence step $i (move) has no current thread id") + val r = runBlocking { + moveXlsxComment(kotlinTarget.absolutePath, path, id, xlsxParitySelector(args.getString("newCell"), (if (args.has("newSheet")) args.getString("newSheet") else null)), home) + } + assertTrue(r is XlsxWriteResult.Ok, "eldenSequence step $i (move) failed: $r") + currentId = (r as XlsxWriteResult.Ok).value + } + else -> fail("eldenSequence: unknown op $op") + } + } + + val lastStep = steps.getJSONObject(steps.length() - 1) + assertEquals("move", lastStep.getString("op"), "eldenSequence's own last step must be the move (this test's own final-state assertions assume it)") + val moveArgs = lastStep.getJSONObject("args") + + val desktopGolden = xlsxParityScratchCopy(xlsxParityResourceBytes("write-golden/elden-sequence.xlsx"), "desktop-elden-sequence.xlsx") + val desktopComments = xlsxParityReadOk(desktopGolden, path) + val kotlinComments = xlsxParityReadOk(kotlinTarget, path) + + val desktopFinal = xlsxParityFindByCell(desktopComments, moveArgs.getString("newCell"), moveArgs.getString("newSheet")) + val kotlinFinal = xlsxParityFindByCell(kotlinComments, moveArgs.getString("newCell"), moveArgs.getString("newSheet")) + assertSameThreadContent(desktopFinal, kotlinFinal, "eldenSequence final thread") + assertEquals(1, kotlinFinal.replies.size, "the reply survives resolve+reopen+move") + // Sequence order is add -> reply -> resolve -> reopen -> move: reopen + // is the LAST op that touches `resolved`, and move never changes it, + // so the final state must be UNRESOLVED. + assertTrue(!kotlinFinal.resolved, "resolve followed by reopen (then a move, which doesn't touch resolved) must leave the thread unresolved") + assertTrue(!desktopFinal.resolved, "desktop's own golden output must show the same final unresolved state") + + // The source sheet (`Sorceries & Incantations List`) must have NO + // thread left at the original cell — the move removed it, it did not + // duplicate it. + val stillAtSource = kotlinComments.any { c -> val s = c.selector; s is CommentSelector.Cell && s.selector.cell == "ZZ999" && s.selector.sheet == "Sorceries & Incantations List" } + assertTrue(!stillAtSource, "the OLD cell must have no thread left after the move") + + // Every one of the ~700 OTHER real threads in this workbook — + // including the other 4 threads sharing B19 — is untouched. + assertUnrelatedThreadsUntouched(desktopComments, kotlinComments, setOf(desktopFinal.id, kotlinFinal.id), "eldenSequence") + } + + // ── Edit/delete build (2026-09-28, design doc §"Edit and delete") — this + // task's own T21 parity extension, previously "not extended in this + // pass" per that section's own closing note. Reuses docling's real F7 + // (root + one reply) and G12 (root only) — the same two threads xlsx- + // comments.test.ts's own edit/delete suite already exercises. ────────── + + @Test + fun editDocling() { + val json = JSONObject(String(xlsxParityResourceBytes("write-golden/edit-docling.json"), Charsets.UTF_8)) + val fixture = json.getString("fixture") + val path = json.getString("path") + val step = json.getJSONArray("steps").getJSONObject(0) + val args = step.getJSONObject("args") + val targetId = args.getString("id") + + val originalBytes = xlsxParityResourceBytes(fixture) + val kotlinTarget = xlsxParityScratchCopy(originalBytes, fixture) + val home = xlsxParityScratchHome() + + val result = runBlocking { editXlsxComment(kotlinTarget.absolutePath, path, targetId, args.getString("text"), home) } + assertTrue(result is XlsxWriteResult.Ok, "Kotlin edit failed: $result") + + val desktopGolden = xlsxParityScratchCopy(xlsxParityResourceBytes("write-golden/edit-docling.xlsx"), "desktop-edit-docling.xlsx") + val desktopComments = xlsxParityReadOk(desktopGolden, path) + val kotlinComments = xlsxParityReadOk(kotlinTarget, path) + + // G12's own pre-existing GUID is never re-minted by edit. + val desktopEdited = desktopComments.find { it.id == targetId } ?: fail("target $targetId missing from desktop golden") + val kotlinEdited = kotlinComments.find { it.id == targetId } ?: fail("target $targetId missing from Kotlin output") + assertSameThreadContentAndId(desktopEdited, kotlinEdited, "editDocling target") + assertEquals(args.getString("text"), kotlinEdited.text, "the edited text should read back") + assertUnrelatedThreadsUntouched(desktopComments, kotlinComments, setOf(targetId), "editDocling") + } + + @Test + fun editReplyDocling() { + val json = JSONObject(String(xlsxParityResourceBytes("write-golden/edit-reply-docling.json"), Charsets.UTF_8)) + val fixture = json.getString("fixture") + val path = json.getString("path") + val step = json.getJSONArray("steps").getJSONObject(0) + val args = step.getJSONObject("args") + val targetId = args.getString("id") + + val originalBytes = xlsxParityResourceBytes(fixture) + val kotlinTarget = xlsxParityScratchCopy(originalBytes, fixture) + val home = xlsxParityScratchHome() + + val result = runBlocking { editXlsxReply(kotlinTarget.absolutePath, path, targetId, args.getString("replyId"), args.getString("text"), home) } + assertTrue(result is XlsxWriteResult.Ok, "Kotlin edit-reply failed: $result") + + val desktopGolden = xlsxParityScratchCopy(xlsxParityResourceBytes("write-golden/edit-reply-docling.xlsx"), "desktop-edit-reply-docling.xlsx") + val desktopComments = xlsxParityReadOk(desktopGolden, path) + val kotlinComments = xlsxParityReadOk(kotlinTarget, path) + + val desktopTarget = desktopComments.find { it.id == targetId } ?: fail("target $targetId missing from desktop golden") + val kotlinTarget2 = kotlinComments.find { it.id == targetId } ?: fail("target $targetId missing from Kotlin output") + assertSameThreadContentAndId(desktopTarget, kotlinTarget2, "editReplyDocling target") + assertEquals(1, kotlinTarget2.replies.size, "the root's single reply must survive an edit") + assertEquals(args.getString("text"), kotlinTarget2.replies[0].text, "the edited reply text should read back") + assertUnrelatedThreadsUntouched(desktopComments, kotlinComments, setOf(targetId), "editReplyDocling") + } + + @Test + fun deleteReplyDocling() { + val json = JSONObject(String(xlsxParityResourceBytes("write-golden/delete-reply-docling.json"), Charsets.UTF_8)) + val fixture = json.getString("fixture") + val path = json.getString("path") + val step = json.getJSONArray("steps").getJSONObject(0) + val args = step.getJSONObject("args") + val targetId = args.getString("id") + + val originalBytes = xlsxParityResourceBytes(fixture) + val kotlinTarget = xlsxParityScratchCopy(originalBytes, fixture) + val home = xlsxParityScratchHome() + + val result = runBlocking { deleteXlsxReply(kotlinTarget.absolutePath, path, targetId, args.getString("replyId"), home) } + assertTrue(result is XlsxWriteResult.Ok, "Kotlin delete-reply failed: $result") + + val desktopGolden = xlsxParityScratchCopy(xlsxParityResourceBytes("write-golden/delete-reply-docling.xlsx"), "desktop-delete-reply-docling.xlsx") + val desktopComments = xlsxParityReadOk(desktopGolden, path) + val kotlinComments = xlsxParityReadOk(kotlinTarget, path) + + // The root survives, with the same pre-existing GUID (never re-minted + // by a reply delete), but now with zero replies. + val desktopTarget = desktopComments.find { it.id == targetId } ?: fail("target $targetId missing from desktop golden") + val kotlinTarget2 = kotlinComments.find { it.id == targetId } ?: fail("target $targetId missing from Kotlin output") + assertSameThreadContentAndId(desktopTarget, kotlinTarget2, "deleteReplyDocling target") + assertEquals(0, kotlinTarget2.replies.size, "the reply must be gone, the root must survive") + assertUnrelatedThreadsUntouched(desktopComments, kotlinComments, setOf(targetId), "deleteReplyDocling") + } + + @Test + fun deleteThreadDocling() { + val json = JSONObject(String(xlsxParityResourceBytes("write-golden/delete-thread-docling.json"), Charsets.UTF_8)) + val fixture = json.getString("fixture") + val path = json.getString("path") + val step = json.getJSONArray("steps").getJSONObject(0) + val args = step.getJSONObject("args") + val targetId = args.getString("id") + + val originalBytes = xlsxParityResourceBytes(fixture) + val kotlinTarget = xlsxParityScratchCopy(originalBytes, fixture) + val home = xlsxParityScratchHome() + + val result = runBlocking { deleteXlsxComment(kotlinTarget.absolutePath, path, targetId, home) } + assertTrue(result is XlsxWriteResult.Ok, "Kotlin delete failed: $result") + + val desktopGolden = xlsxParityScratchCopy(xlsxParityResourceBytes("write-golden/delete-thread-docling.xlsx"), "desktop-delete-thread-docling.xlsx") + val desktopComments = xlsxParityReadOk(desktopGolden, path) + val kotlinComments = xlsxParityReadOk(kotlinTarget, path) + + assertTrue(desktopComments.none { it.id == targetId }, "deleted thread must be gone from desktop's golden") + assertTrue(kotlinComments.none { it.id == targetId }, "deleted thread must be gone from Kotlin's output") + // G12's own independent thread on the same sheet survives untouched + // on BOTH sides. + assertTrue(kotlinComments.any { c -> val s = c.selector; s is CommentSelector.Cell && s.selector.cell == "G12" }, "G12's independent thread must survive") + assertUnrelatedThreadsUntouched(desktopComments, kotlinComments, setOf(targetId), "deleteThreadDocling") + } + + // ── delete-last-comment-fresh: add the ONLY comment to a brand-new, + // comment-free workbook, then delete it — proves Kotlin's own + // `cleanupEmptyCommentPartsIfNeeded` removes every comment part/rel/ + // content-type override/`` the same way desktop's does, + // leaving the workbook exactly as if it never had comments. ──────────── + @Test + fun deleteLastCommentFresh() { + val json = JSONObject(String(xlsxParityResourceBytes("write-golden/delete-last-comment-fresh.json"), Charsets.UTF_8)) + val fixture = json.getString("fixture") + val path = json.getString("path") + val steps = json.getJSONArray("steps") + val addArgs = steps.getJSONObject(0).getJSONObject("args") + + val originalBytes = xlsxParityResourceBytes(fixture) + val kotlinTarget = xlsxParityScratchCopy(originalBytes, fixture) + val home = xlsxParityScratchHome() + + val addResult = runBlocking { + addXlsxComment(kotlinTarget.absolutePath, path, xlsxParitySelector(addArgs.getString("cell"), null), addArgs.getString("text"), addArgs.getString("author"), home) + } + assertTrue(addResult is XlsxWriteResult.Ok, "Kotlin add failed: $addResult") + val newId = (addResult as XlsxWriteResult.Ok).value + + val deleteResult = runBlocking { deleteXlsxComment(kotlinTarget.absolutePath, path, newId, home) } + assertTrue(deleteResult is XlsxWriteResult.Ok, "Kotlin delete failed: $deleteResult") + + val kotlinRead = readXlsxComments(kotlinTarget, path) + assertTrue(kotlinRead is XlsxReadResult.Ok, "expected Ok, got $kotlinRead") + assertEquals(0, (kotlinRead as XlsxReadResult.Ok).comments.size, "the workbook must read back with zero comments") + + // Same structural cleanup as desktop's own committed golden: every + // comment part, its rels, content-type overrides and the worksheet's + // own are all gone. + val entries = xlsxParityEntryNames(kotlinTarget) + assertTrue("xl/comments1.xml" !in entries, "comments1.xml must be gone") + assertTrue("xl/drawings/vmlDrawing1.vml" !in entries, "vmlDrawing1.vml must be gone") + assertTrue("xl/threadedComments/threadedComment1.xml" !in entries, "threadedComment1.xml must be gone") + val worksheetXml = String(xlsxParityEntryBytes(kotlinTarget, "xl/worksheets/sheet1.xml") ?: fail("sheet1.xml missing"), Charsets.UTF_8) + assertTrue(!worksheetXml.contains("legacyDrawing"), "legacyDrawing must be gone") + + // The committed desktop golden itself must show the identical + // zero-comments, parts-gone end state. + val desktopGolden = xlsxParityScratchCopy(xlsxParityResourceBytes("write-golden/delete-last-comment-fresh.xlsx"), "desktop-delete-last-comment-fresh.xlsx") + val desktopRead = readXlsxComments(desktopGolden, path) + assertTrue(desktopRead is XlsxReadResult.Ok, "expected Ok, got $desktopRead") + assertEquals(0, (desktopRead as XlsxReadResult.Ok).comments.size, "desktop's golden must also read back with zero comments") + val desktopEntries = xlsxParityEntryNames(desktopGolden) + assertTrue("xl/comments1.xml" !in desktopEntries, "desktop golden: comments1.xml must be gone") + assertTrue("xl/threadedComments/threadedComment1.xml" !in desktopEntries, "desktop golden: threadedComment1.xml must be gone") + } +} diff --git a/app/src/test/kotlin/com/youcoded/app/doccomments/XlsxCommentsTest.kt b/app/src/test/kotlin/com/youcoded/app/doccomments/XlsxCommentsTest.kt new file mode 100644 index 000000000..4ba2f5d51 --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/doccomments/XlsxCommentsTest.kt @@ -0,0 +1,1185 @@ +// Pins T18 (read) and T19 (write) of the doc-comments build — REWRITTEN +// 2026-09-27 for Destin's threaded-comments-only decision (docs/active/specs/ +// 2026-09-26-doc-comments-build-design.md §4.2/§4.3/§4.3a). Mirrors desktop's +// own `desktop/tests/xlsx-comments.test.ts` (T12/T13), a plain `./gradlew +// test` JVM unit test — no Android framework classes, no instrumented test. +// +// Fixtures: app/src/test/resources/doc-comments/{docling-xlsx-comments, +// elden-ring-completionist-checklist,synthetic-worksheet-with-extlst}.xlsx — +// the SAME two real, license-checked reference files (plus the shared +// synthetic extLst fixture) desktop's own T12/T13 tests read, copied verbatim +// from `shared-fixtures/doc-comments/{xlsx-threaded-reference/, +// synthetic-worksheet-with-extlst.xlsx}` — a fixture drift here would +// silently make the cross-platform parity claim (T21) compare two different +// inputs. `q3-sales-by-rep.xlsx` (already present, legacy-Notes-only) is kept +// for the "Notes are never shown" regression case §4.1 names by name. +// +// The shared id-parse test vectors (design review round 2, F2) are read +// directly off `shared-fixtures/doc-comments/id-parse-test-vectors.json` +// (never copied into test resources) — the same file desktop's own +// `xlsx-comments.test.ts` reads, so a fix to one runtime's parser that the +// other doesn't share fails immediately on this shared contract. +package com.youcoded.app.doccomments + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Test +import java.io.File +import java.nio.file.Files +import java.util.Collections +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream +import kotlinx.coroutines.test.runTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** The zone `generate-xlsx-golden.mjs` pins while writing the goldens. + * + * WHY (2026-09-28 PR review): a threaded comment's `dT` carries no time zone, + * and both readers treat it as LOCAL time (the design's deliberate choice), so + * a golden `createdAt` is only correct in the zone it was generated in. These + * two tests passed on the author's Arizona machine and failed on GitHub's UTC + * runners. Running them in the generator's own zone makes them pass anywhere + * while still checking every value exactly. */ +private const val GOLDEN_TIME_ZONE = "America/Phoenix" + +private fun inGoldenTimeZone(block: () -> Unit) { + val previous = java.util.TimeZone.getDefault() + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone(GOLDEN_TIME_ZONE)) + try { + block() + } finally { + java.util.TimeZone.setDefault(previous) + } +} + +private fun fixtureFile(name: String): File { + val resourceStream = object {}.javaClass.getResourceAsStream("/doc-comments/$name") + ?: fail("missing test resource doc-comments/$name") + val tmp = Files.createTempFile("ycd-xlsx-", "-$name").toFile() + tmp.deleteOnExit() + resourceStream.use { input -> tmp.outputStream().use { output -> input.copyTo(output) } } + return tmp +} + +/** Walks upward from the current working directory looking for a shared, + * cross-runtime fixture under `shared-fixtures/` at the repo root — never + * copied into `app/src/test/resources/`, so a fix to this Kotlin module's + * parser and desktop's own TS parser both read the identical file (design + * review round 2, F2's own "one shared fixture, not two independently + * hand-built ones" discipline). Gradle's own working directory for `test` + * varies by invocation, so this searches rather than assumes one. */ +private fun findSharedFixture(relative: String): File { + var dir: File? = File(".").canonicalFile + while (dir != null) { + val candidate = File(dir, relative) + if (candidate.exists()) return candidate + dir = dir.parentFile + } + fail("could not locate shared fixture $relative from ${File(".").canonicalPath}") +} + +private fun tempHome(): File = Files.createTempDirectory("ycd-xlsx-home-").toFile().apply { deleteOnExit() } + +/** Copies `source`, replacing exactly one named zip entry's content — used to + * hand-craft the malformed/adversarial shapes (a duplicate-GUID pair, a + * record count past the ceiling) neither real fixture has, the same + * "hand-crafted, since neither real file has one" precedent desktop's own + * T13 pinning tests use for these exact cases. */ +private fun withReplacedZipEntry(source: File, entryName: String, newContent: ByteArray): File { + val out = Files.createTempFile("ycd-xlsx-mod-", ".xlsx").toFile() + out.deleteOnExit() + ZipFile(source).use { zin -> + ZipOutputStream(out.outputStream()).use { zout -> + for (entry in Collections.list(zin.entries())) { + if (entry.isDirectory) continue + val bytes = if (entry.name == entryName) newContent else zin.getInputStream(entry).use { it.readBytes() } + zout.putNextEntry(ZipEntry(entry.name)) + zout.write(bytes) + zout.closeEntry() + } + } + } + return out +} + +private fun readZipEntryText(file: File, name: String): String? = + ZipFile(file).use { zip -> zip.getEntry(name)?.let { zip.getInputStream(it).use { s -> s.readBytes() } }.let { it?.toString(Charsets.UTF_8) } } + +/** F1 (High, android-xlsx-review) test fixture builder: copies `source`, + * ADDING one brand-new entry with `ZipEntry.STORED` (uncompressed) — + * mirrors a real embedded image or other already-compressed binary asset + * (real Excel workbooks routinely store their `xl/media` images this way). + * Used to + * prove the fix's raw-passthrough path: a STORED entry this module's own + * mutation never touches must come back byte-identical AND still `STORED` + * (never re-encoded to `DEFLATED`), which only holds if it was streamed + * through untouched rather than decompressed-then-rewritten. */ +private fun withAddedStoredZipEntry(source: File, entryName: String, content: ByteArray): File { + val out = Files.createTempFile("ycd-xlsx-stored-", ".xlsx").toFile() + out.deleteOnExit() + val crc = java.util.zip.CRC32().apply { update(content) }.value + ZipFile(source).use { zin -> + ZipOutputStream(out.outputStream()).use { zout -> + for (entry in Collections.list(zin.entries())) { + if (entry.isDirectory) continue + val bytes = zin.getInputStream(entry).use { it.readBytes() } + zout.putNextEntry(ZipEntry(entry.name)) + zout.write(bytes) + zout.closeEntry() + } + val newEntry = ZipEntry(entryName) + newEntry.method = ZipEntry.STORED + newEntry.size = content.size.toLong() + newEntry.compressedSize = content.size.toLong() + newEntry.crc = crc + zout.putNextEntry(newEntry) + zout.write(content) + zout.closeEntry() + } + } + return out +} + +/** F1 test fixture builder: copies `source`, adding one brand-new `DEFLATED` + * (the zip default) entry — used for the "a large-image workbook doesn't + * require whole-archive buffering" case. `content` is deliberately + * ALL-ZERO bytes, so the resulting `.xlsx` stays small and fast to write to + * disk in a unit test even though the entry's DECLARED uncompressed size is + * large (tens of MB) — the same "declared vs. actual" shape a real + * embedded image or big untouched worksheet has, without this test needing + * to actually allocate/write that much real entropy to disk. */ +private fun withAddedDeflatedZipEntry(source: File, entryName: String, content: ByteArray): File { + val out = Files.createTempFile("ycd-xlsx-deflated-", ".xlsx").toFile() + out.deleteOnExit() + ZipFile(source).use { zin -> + ZipOutputStream(out.outputStream()).use { zout -> + for (entry in Collections.list(zin.entries())) { + if (entry.isDirectory) continue + val bytes = zin.getInputStream(entry).use { it.readBytes() } + zout.putNextEntry(ZipEntry(entry.name)) + zout.write(bytes) + zout.closeEntry() + } + zout.putNextEntry(ZipEntry(entryName)) + zout.write(content) + zout.closeEntry() + } + } + return out +} + +// ── T21: golden-fixture parity against the REAL desktop reader's own output +// (docs/active/specs/2026-09-26-doc-comments-build-design.md §9.3) — before +// this, T12's desktop test and T18's Kotlin test each independently +// hand-wrote their own expectations against the SAME two real files, which +// can agree by construction without either implementation's ACTUAL output +// ever being cross-checked against the other. `goldenComments`/ +// `assertJsonEquals` mirror `DocxCommentsTest.kt`'s own helpers of the same +// name verbatim (file-private in Kotlin, so duplicated rather than shared — +// see that file's own header for the `.keys()`-vs-`.keySet()` note). +private fun goldenComments(name: String): JSONArray { + val stream = object {}.javaClass.getResourceAsStream("/doc-comments/golden/$name") + ?: fail("missing golden fixture doc-comments/golden/$name") + val json = stream.use { it.readBytes().toString(Charsets.UTF_8) } + return JSONObject(json).getJSONArray("comments") +} + +private fun keysOfXlsx(o: JSONObject): Set { + val out = mutableSetOf() + val it = o.keys() + while (it.hasNext()) out.add(it.next()) + return out +} + +private fun assertJsonEqualsXlsx(expected: Any?, actual: Any?, path: String) { + when (expected) { + is JSONObject -> { + assertTrue(actual is JSONObject, "$path: expected an object, got $actual") + actual as JSONObject + val expectedKeys = keysOfXlsx(expected) + assertEquals(expectedKeys, keysOfXlsx(actual), "$path: key set differs") + for (key in expectedKeys) { + assertJsonEqualsXlsx(expected.get(key), actual.get(key), "$path.$key") + } + } + is JSONArray -> { + assertTrue(actual is JSONArray, "$path: expected an array, got $actual") + actual as JSONArray + assertEquals(expected.length(), actual.length(), "$path: array length differs") + for (i in 0 until expected.length()) { + assertJsonEqualsXlsx(expected.get(i), actual.get(i), "$path[$i]") + } + } + else -> assertEquals(expected, actual, path) + } +} + +private fun assertMatchesGoldenXlsx(fixtureBaseName: String, comments: List) { + val expected = goldenComments("$fixtureBaseName.json") + // Order-independent: neither reader's own comment enumeration order is + // part of the contract (unlike a thread's OWN replies, which are always + // order-significant) — both are sorted by id first, the same way + // `DocxCommentsCrossPlatformParityTest.kt` sorts before comparing. + val actual = JSONArray(comments.sortedBy { it.id }.map { it.toJson() }) + val expectedSorted = JSONArray((0 until expected.length()).map { expected.getJSONObject(it) }.sortedBy { it.getString("id") }) + assertJsonEqualsXlsx(expectedSorted, actual, fixtureBaseName) +} + +private fun cellSelector(cell: String, sheet: String? = null): CommentSelector = CommentSelector.Cell(CellSelector(cell, sheet)) + +private fun commentAt(comments: List, cell: String): List = + comments.filter { it.selector is CommentSelector.Cell && (it.selector as CommentSelector.Cell).selector.cell == cell } + +class XlsxCommentsTest { + + // ── T21: golden-fixture parity against the REAL desktop reader ───────── + // (design §9.3) — generated by `desktop/tests/fixtures/doc-comments/ + // generate-xlsx-golden.mjs`, which runs the real TS `readXlsxComments` + // against these SAME two files. Regenerating that golden while writing + // this test caught a real, live drift: the committed `q3-sales-by-rep.json`/ + // `chartsheet-workbook.json` still reflected the RETIRED legacy-Notes + // reader's output (non-empty, `x-...`-id comments) from before the + // 2026-09-27 threaded-comments-only redesign — nothing had re-run the + // generator since, and nothing read those two goldens for a real + // assertion (only the byte-identity drift guard touched them, which + // checks desktop==android, never desktop==reality). Fixed alongside this + // test by regenerating both files against the CURRENT reader (§4.1: a + // Notes-only workbook now correctly reads back as zero comments) — see + // `generate-xlsx-golden.mjs`'s own updated header for the full story. + @Test + fun doclingXlsxCommentsMatchesTheDesktopReaderFieldForField() = inGoldenTimeZone { + val result = readXlsxComments(fixtureFile("docling-xlsx-comments.xlsx"), "reports/docling-xlsx-comments.xlsx") + assertTrue(result is XlsxReadResult.Ok, "expected Ok, got $result") + assertMatchesGoldenXlsx("docling-xlsx-comments", (result as XlsxReadResult.Ok).comments) + } + + @Test + fun eldenRingCompletionistChecklistMatchesTheDesktopReaderFieldForField() = inGoldenTimeZone { + val result = readXlsxComments(fixtureFile("elden-ring-completionist-checklist.xlsx"), "reports/elden-ring-completionist-checklist.xlsx") + assertTrue(result is XlsxReadResult.Ok, "expected Ok, got $result") + val comments = (result as XlsxReadResult.Ok).comments + // The real B19 cell's 5 independent threads (§4.2/§9.3's own named + // highest-risk shape) — asserted explicitly, in addition to the + // full-file golden comparison below, since a bug that merged/dropped + // exactly these 5 records among 710 total could otherwise hide inside + // an aggregate array-length match if it happened to be offset by an + // unrelated difference elsewhere. (It can't be, since the golden + // comparison is field-for-field over the WHOLE array — this is a + // belt-and-suspenders assertion naming the specific risk.) + val b19 = commentAt(comments, "B19") + assertEquals(5, b19.size, "elden's real B19 cell carries 5 independent threads") + assertMatchesGoldenXlsx("elden-ring-completionist-checklist", comments) + } + + // ── Read: the real Excel-authored (docling) fixture ──────────────────── + + @Test + fun `reads an unresolved root+reply thread on F7 and a root-only thread on G12, from real Excel`() { + val result = readXlsxComments(fixtureFile("docling-xlsx-comments.xlsx"), "reports/docling.xlsx") + assertTrue(result is XlsxReadResult.Ok, "expected Ok, got $result") + val comments = (result as XlsxReadResult.Ok).comments + + val f7 = commentAt(comments, "F7") + assertEquals(1, f7.size) + assertEquals("Minimum number of saltwater ducks", f7[0].text) + assertEquals("person:Jane Smith (JS)", f7[0].author) + assertFalse(f7[0].resolved) + assertEquals(1, f7[0].replies.size) + assertEquals("I never thought it would be so low", f7[0].replies[0].text) + assertEquals("person:Marcus Sterling (MS)", f7[0].replies[0].author) + assertEquals("${f7[0].id}-r1", f7[0].replies[0].id) + + val g12 = commentAt(comments, "G12") + assertEquals(1, g12.size) + assertEquals("Maximum number of ducks", g12[0].text) + assertFalse(g12[0].resolved) + assertTrue(g12[0].replies.isEmpty()) + } + + @Test + fun `never surfaces the genuine Notes on A1 or B2, from the SAME xl comments1 xml`() { + val result = readXlsxComments(fixtureFile("docling-xlsx-comments.xlsx"), "reports/docling.xlsx") + val comments = (result as XlsxReadResult.Ok).comments + assertTrue(commentAt(comments, "A1").isEmpty()) + assertTrue(commentAt(comments, "B2").isEmpty()) + assertEquals(2, comments.size, "only F7 and G12 are threaded comments in this fixture") + } + + @Test + fun `stamps the single-sheet docling workbook with no sheet field on the selector`() { + val result = readXlsxComments(fixtureFile("docling-xlsx-comments.xlsx"), "reports/docling.xlsx") + val comments = (result as XlsxReadResult.Ok).comments + for (c in comments) { + val sel = c.selector + assertTrue(sel is CommentSelector.Cell) + assertNull((sel as CommentSelector.Cell).selector.sheet) + } + } + + // ── Read: the real Google-Sheets-exported (x18tc:-prefixed) fixture ──── + + @Test + fun `reads the SAME shape from an x18tc-prefixed Google Sheets file (namespace-agnostic parsing)`() { + val result = readXlsxComments(fixtureFile("elden-ring-completionist-checklist.xlsx"), "reports/elden.xlsx") + assertTrue(result is XlsxReadResult.Ok, "expected Ok, got $result") + val comments = (result as XlsxReadResult.Ok).comments + assertTrue(comments.isNotEmpty(), "a Google-Sheets x18tc:-prefixed file must not read as zero comments") + // Multi-sheet workbook (10 sheets) — every comment's selector stamps + // a `sheet` name, unlike the single-sheet docling fixture above. + for (c in comments) { + val sel = c.selector as CommentSelector.Cell + assertNotNull(sel.selector.sheet, "a multi-sheet workbook must stamp `sheet` on every selector") + } + } + + @Test + fun `groups cell B19 into FIVE separate, independent threads, none dropped or merged`() { + val result = readXlsxComments(fixtureFile("elden-ring-completionist-checklist.xlsx"), "reports/elden.xlsx") + val comments = (result as XlsxReadResult.Ok).comments + val b19 = commentAt(comments, "B19") + assertEquals(5, b19.size, "elden's real B19 cell carries 5 independent threads") + // Reply counts per root, confirmed directly against the real fixture + // excerpt (elden-threadedComment1-B19-excerpt.xml): 3, 0, 1, 0, 0 — + // in SOME order (root document order, not sorted). + val replyCounts = b19.map { it.replies.size }.sorted() + assertEquals(listOf(0, 0, 0, 1, 3), replyCounts) + // No two of the five share an app-level id. + assertEquals(5, b19.map { it.id }.toSet().size) + } + + // ── Read: a workbook with ONLY genuine Notes (no threaded comments) ──── + + @Test + fun `returns ZERO comments for a legacy-Notes-only workbook, never the retired reader's garbled output`() { + val result = readXlsxComments(fixtureFile("q3-sales-by-rep.xlsx"), "reports/q3-sales-by-rep.xlsx") + assertTrue(result is XlsxReadResult.Ok, "expected Ok, got $result") + assertEquals(emptyList(), (result as XlsxReadResult.Ok).comments) + } + + // ── The id parser: shared contract with desktop's TS port ────────────── + + @Test + fun `parses every entry in the shared id-parse-test-vectors json identically to the pre-written regex`() { + val fixture = findSharedFixture("shared-fixtures/doc-comments/id-parse-test-vectors.json") + val json = JSONObject(fixture.readText()) + val vectors = json.getJSONArray("vectors") + assertTrue(vectors.length() > 0) + for (i in 0 until vectors.length()) { + val v = vectors.getJSONObject(i) + val input = v.getString("input") + val parsed = parseXlsxThreadId(input) + assertNotNull(parsed, "expected ${v.getString("case")} to parse: $input") + assertEquals(v.getInt("sheetId"), parsed!!.sheetId, "sheetId for ${v.getString("case")}") + assertEquals(v.getString("cell"), parsed.cell, "cell for ${v.getString("case")}") + assertEquals(v.getString("guid"), parsed.guid, "guid for ${v.getString("case")}") + } + } + + // ── Write: add ─────────────────────────────────────────────────────── + + @Test + fun `add creates a new thread on a fresh cell and wires all four new parts`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val result = addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("C1"), "New thread here", "user", home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + val newId = (result as XlsxWriteResult.Ok).value + + val read = readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok + val added = read.comments.find { it.id == newId } + assertNotNull(added) + assertEquals("New thread here", added!!.text) + // A round trip through persons.xml only ever preserves the DISPLAY + // NAME ("You" for 'user' — commentAuthorToDisplayName), never the + // original 'user'/'assistant'/'person:X' tag itself — the xlsx OOXML + // format has no field for that distinction. Desktop's own T13 + // pinning test asserts the identical `person:You` shape after this + // exact round trip (xlsx-comments.test.ts). + assertEquals("person:You", added.author) + assertFalse(added.resolved) + assertTrue(added.replies.isEmpty()) + + // The pre-existing F7/G12 threads (and the genuine Notes on A1/B2) + // must survive this write untouched. + assertEquals(1, commentAt(read.comments, "F7").size) + assertEquals(1, commentAt(read.comments, "G12").size) + } + + @Test + fun `refuses cell-has-note when the target already carries a genuine Note`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val result = addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("A1"), "x", "user", tempHome()) + assertEquals(XlsxWriteResult.Err(XlsxWriteError.CELL_HAS_NOTE), result) + } + + @Test + fun `refuses cell-already-has-comment when the target already carries ANY thread`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val result = addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("F7"), "x", "user", tempHome()) + assertEquals(XlsxWriteResult.Err(XlsxWriteError.CELL_ALREADY_HAS_COMMENT), result) + } + + @Test + fun `reuses, never duplicates, this app's own person entry across two writes by the same identity`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + assertTrue(addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("C1"), "one", "user", home) is XlsxWriteResult.Ok) + assertTrue(addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("D1"), "two", "user", home) is XlsxWriteResult.Ok) + + val personsXml = readZipEntryText(xlsx, "xl/persons/person.xml") ?: fail("expected a persons.xml part after two writes") + val count = Regex("providerId=\"YouCoded\"").findAll(personsXml).count() + assertEquals(1, count, "two writes by the SAME identity must reuse one person entry, not mint two") + } + + @Test + fun `mints a fresh person entry per distinct display name`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + assertTrue(addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("C1"), "one", "user", home) is XlsxWriteResult.Ok) + assertTrue(addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("D1"), "two", "assistant", home) is XlsxWriteResult.Ok) + val personsXml = readZipEntryText(xlsx, "xl/persons/person.xml")!! + assertEquals(2, Regex("providerId=\"YouCoded\"").findAll(personsXml).count()) + } + + // ── Write: reply ──────────────────────────────────────────────────── + + @Test + fun `reply appends to the ROOT and rebuilds the legacy placeholder body`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val g12Id = commentAt(before, "G12").single().id + + val result = replyToXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", g12Id, "a fresh reply", "user", home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + val reply = (result as XlsxWriteResult.Ok).value + assertEquals("a fresh reply", reply.text) + assertEquals("$g12Id-r1", reply.id) + + val after = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val g12 = commentAt(after, "G12").single() + assertEquals(1, g12.replies.size) + assertEquals("a fresh reply", g12.replies[0].text) + + // Legacy placeholder body was rebuilt to include the new reply, in + // real-Excel layout — §4.2's own exact shape. + val commentsXml = readZipEntryText(xlsx, "xl/comments1.xml")!! + assertTrue(commentsXml.contains("Reply:\n a fresh reply")) + } + + @Test + fun `reply refuses comment-not-found for an id with a well-formed shape but no matching thread`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val result = replyToXlsxComment( + xlsx.absolutePath, "reports/docling.xlsx", + "xt-1-Z99-00000000-0000-0000-0000-000000000000", "x", "user", tempHome(), + ) + assertEquals(XlsxWriteError.COMMENT_NOT_FOUND, (result as XlsxWriteResult.Err).error) + } + + // ── Write: resolve / reopen ───────────────────────────────────────── + + @Test + fun `resolve sets done on the root only, and NEVER touches the legacy placeholder text`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val g12Id = commentAt(before, "G12").single().id + val placeholderBefore = readZipEntryText(xlsx, "xl/comments1.xml")!! + + val result = resolveXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", g12Id, home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + + val after = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + assertTrue(commentAt(after, "G12").single().resolved) + val placeholderAfter = readZipEntryText(xlsx, "xl/comments1.xml")!! + assertEquals(placeholderBefore, placeholderAfter, "resolve must never touch the legacy placeholder text") + } + + @Test + fun `reopen REMOVES the done attribute entirely, never writes done equals 0`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val g12Id = commentAt(before, "G12").single().id + + assertTrue(resolveXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", g12Id, home) is XlsxWriteResult.Ok) + val reopenResult = reopenXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", g12Id, home) + assertTrue(reopenResult is XlsxWriteResult.Ok, "expected Ok, got $reopenResult") + + val threadedXml = readZipEntryText(xlsx, "xl/threadedComments/threadedComment1.xml")!! + assertFalse(threadedXml.contains("done=\"0\""), "reopen must remove `done` entirely, never write done=\"0\"") + val after = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + assertFalse(commentAt(after, "G12").single().resolved) + } + + @Test + fun `resolving one of FIVE independent threads on the same cell never disturbs its siblings`() = runTest { + val xlsx = fixtureFile("elden-ring-completionist-checklist.xlsx") + val home = tempHome() + val before = (readXlsxComments(xlsx, "reports/elden.xlsx") as XlsxReadResult.Ok).comments + val b19Before = commentAt(before, "B19") + assertEquals(5, b19Before.size) + // The real elden fixture's own B19 excerpt has all 5 roots done="1" + // at the time this fixture was captured — pick an unresolved one if + // any exist (exercising reopen->resolve wouldn't be as interesting), + // otherwise fall back to the first root and reopen it instead. + val target = b19Before.firstOrNull { !it.resolved } ?: b19Before.first() + val targetId = target.id + val wasResolved = target.resolved + + val result = if (wasResolved) reopenXlsxComment(xlsx.absolutePath, "reports/elden.xlsx", targetId, home) + else resolveXlsxComment(xlsx.absolutePath, "reports/elden.xlsx", targetId, home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + + val after = (readXlsxComments(xlsx, "reports/elden.xlsx") as XlsxReadResult.Ok).comments + val b19After = commentAt(after, "B19") + assertEquals(5, b19After.size, "no sibling thread on B19 was created or dropped") + val changed = b19After.find { it.id == targetId }!! + assertEquals(!wasResolved, changed.resolved) + // Every OTHER root's own resolved state and reply count is untouched. + for (before2 in b19Before.filter { it.id != targetId }) { + val after2 = b19After.find { it.id == before2.id }!! + assertEquals(before2.resolved, after2.resolved) + assertEquals(before2.replies.size, after2.replies.size) + } + } + + // ── Write: move ───────────────────────────────────────────────────── + + @Test + fun `move relocates a thread to a new cell, keeping the same id-chain and text`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val g12Id = commentAt(before, "G12").single().id + + val result = moveXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", g12Id, cellSelector("H20"), home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + val freshId = (result as XlsxWriteResult.Ok).value + assertTrue(freshId.contains("-H20-"), "a moved thread's fresh id embeds the NEW cell, got $freshId") + + val after = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + assertTrue(commentAt(after, "G12").isEmpty(), "the OLD cell must no longer carry this thread") + val moved = commentAt(after, "H20").single() + assertEquals("Maximum number of ducks", moved.text) + assertFalse(moved.resolved) + } + + @Test + fun `refuses destination-cell-occupied when the destination already carries a DIFFERENT thread`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val g12Id = commentAt(before, "G12").single().id + val result = moveXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", g12Id, cellSelector("F7"), tempHome()) + assertEquals(XlsxWriteError.DESTINATION_CELL_OCCUPIED, (result as XlsxWriteResult.Err).error) + } + + @Test + fun `refuses cell-has-note when move's destination already carries a genuine Note`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val g12Id = commentAt(before, "G12").single().id + val result = moveXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", g12Id, cellSelector("A1"), tempHome()) + assertEquals(XlsxWriteError.CELL_HAS_NOTE, (result as XlsxWriteResult.Err).error) + } + + @Test + fun `move never mints fresh GUIDs — reply history and resolve state survive exactly`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val f7Before = commentAt(before, "F7").single() + + val result = moveXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", f7Before.id, cellSelector("K1"), home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + + val after = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val moved = commentAt(after, "K1").single() + assertEquals(f7Before.replies.size, moved.replies.size) + assertEquals(f7Before.replies[0].text, moved.replies[0].text) + assertEquals(f7Before.replies[0].author, moved.replies[0].author) + assertEquals(f7Before.text, moved.text) + } + + // ── Illegal XML control characters: STRIPPED, not refused ──────────── + + @Test + fun `strips XML-illegal control characters from comment text instead of refusing`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val dirty = "before\u0001\u0002\u001Fafter" + val result = addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("C1"), dirty, "user", home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok (stripped, not refused), got $result") + val newId = (result as XlsxWriteResult.Ok).value + val added = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments.find { it.id == newId }!! + assertEquals("beforeafter", added.text) + } + + @Test + fun `still allows tab, newline and carriage return in comment text`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val text = "a\tb\nc\rd" + val result = addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("C1"), text, "user", home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + val newId = (result as XlsxWriteResult.Ok).value + val added = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments.find { it.id == newId }!! + assertEquals(text, added.text) + } + + // ── legacyDrawing-after-extLst ordering (synthetic fixture) ────────── + + @Test + // 2026-09-28 PR review: the schema puts legacyDrawing BEFORE tableParts + // and extLst; this test used to pin the opposite, and Excel reported "We + // found a problem with some content" for exactly those files. + fun `lands legacyDrawing BEFORE a pre-existing worksheet-level extLst, where the schema puts it`() = runTest { + val xlsx = fixtureFile("synthetic-worksheet-with-extlst.xlsx") + val home = tempHome() + // Discover the single real sheet's own part path so this test doesn't + // need to hardcode the synthetic fixture's own worksheet numbering. + val before = readXlsxComments(xlsx, "reports/synthetic.xlsx") + assertTrue(before is XlsxReadResult.Ok, "expected Ok, got $before") + + val result = addXlsxComment(xlsx.absolutePath, "reports/synthetic.xlsx", cellSelector("A1"), "first comment", "user", home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + + val worksheetXml = readZipEntryText(xlsx, "xl/worksheets/sheet1.xml") ?: fail("expected xl/worksheets/sheet1.xml") + val extLstIdx = worksheetXml.indexOf("= 0, "the synthetic fixture must already have an extLst — the whole point of this test") + assertTrue(legacyDrawingIdx in 0 until extLstIdx, "legacyDrawing must land BEFORE extLst") + } + + @Test + fun `lands legacyDrawing BEFORE tableParts on a sheet that holds an Excel Table`() = runTest { + val xlsx = fixtureFile("synthetic-worksheet-with-extlst.xlsx") + // Rewrite the synthetic sheet with a right before its + // , the shape every sheet with an Excel Table has. + val entries = java.util.zip.ZipFile(xlsx).use { zip -> + zip.entries().toList().map { e -> e.name to zip.getInputStream(e).use { it.readBytes() } } + } + java.util.zip.ZipOutputStream(xlsx.outputStream()).use { zos -> + for ((name, bytes) in entries) { + zos.putNextEntry(java.util.zip.ZipEntry(name)) + val out = if (name == "xl/worksheets/sheet1.xml") { + bytes.toString(Charsets.UTF_8).replace("", """""").toByteArray(Charsets.UTF_8) + } else bytes + zos.write(out) + zos.closeEntry() + } + } + val result = addXlsxComment(xlsx.absolutePath, "reports/synthetic.xlsx", cellSelector("A1"), "first comment", "user", tempHome()) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + val sheet = readZipEntryText(xlsx, "xl/worksheets/sheet1.xml") ?: fail("expected xl/worksheets/sheet1.xml") + val order = listOf("= 0 } && order == order.sorted(), "expected legacyDrawing < tableParts < extLst, got $order") + } + + // ── The record-count ceiling (design review 1, F3) ─────────────────── + + @Test + fun `refuses a read past the record-count ceiling with a distinct, typed error`() { + val base = fixtureFile("docling-xlsx-comments.xlsx") + // MAX_COMMENT_RECORDS (private in XlsxComments.kt) mirrors desktop's + // own 20000 — hardcoded here the same way desktop's own test hardcodes + // its ceiling-plus-one fixture, rather than exporting an + // implementation constant purely for a test to read. + val many = buildString { + append("") + append("") + repeat(20001) { i -> append("x") } + append("") + } + val modified = withReplacedZipEntry(base, "xl/threadedComments/threadedComment1.xml", many.toByteArray(Charsets.UTF_8)) + val result = readXlsxComments(modified, "reports/many.xlsx") + assertEquals(XlsxReadResult.Err(XlsxReadError.TOO_MANY_COMMENTS), result) + } + + // ── Ambiguous-id refusal (design review round 2, F3) ───────────────── + + @Test + fun `refuses ambiguous-comment-id when the fallback scan finds TWO roots sharing one GUID`() = runTest { + val base = fixtureFile("docling-xlsx-comments.xlsx") + val dupGuid = "{AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}" + val threaded = "" + + "" + + "one" + + "two" + + "" + val modified = withReplacedZipEntry(base, "xl/threadedComments/threadedComment1.xml", threaded.toByteArray(Charsets.UTF_8)) + // sheetId 999 never resolves, forcing straight past the hinted lookup + // into the fallback scan, which must find BOTH roots and refuse + // rather than silently acting on whichever the scan order hits first. + val id = "xt-999-Z1-${dupGuid.replace("{", "").replace("}", "")}" + val result = resolveXlsxComment(modified.absolutePath, "reports/dup.xlsx", id, tempHome()) + assertEquals(XlsxWriteError.AMBIGUOUS_COMMENT_ID, (result as XlsxWriteResult.Err).error) + } + + // ── Verify-after-write, with rollback semantics (verify-BEFORE-replace) ── + + @Test + fun `a forced verify failure never touches the real target file`() = runTest { + // Mirrors DocxCommentsWriteTest.kt's own "a failed verification never + // touches the target and never creates a backup" — the `mutate` + // closure here is a trivial pass-through (a real archive mutation + // isn't the point of this test; the pipeline's OWN ordering is), the + // same simplification that test uses. + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val originalBytes = xlsx.readBytes() + val home = tempHome() + val backupPath = xlsxBackupPathFor(home, xlsx.absolutePath) + assertFalse(backupPath.exists()) + + val result = writeXlsxMutation( + xlsx.absolutePath, + home, + mutate = { workCopy, outFile -> workCopy.copyTo(outFile, overwrite = true); XlsxWriteResult.Ok(Unit) }, + verify = { _, _, _ -> false }, // the injected fault: verification always fails + ) + assertEquals(XlsxWriteError.VERIFY_FAILED, (result as XlsxWriteResult.Err).error) + assertTrue(originalBytes.contentEquals(xlsx.readBytes()), "a failed verify must leave the target byte-identical to the original") + assertFalse(backupPath.exists(), "no backup should be written when verify never lets the pipeline get that far") + } + + @Test + fun `leaves a rolling backup at the documented, hashed path after a successful write`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val originalBytes = xlsx.readBytes() + val home = tempHome() + assertTrue(addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("C1"), "x", "user", home) is XlsxWriteResult.Ok) + val backup = xlsxBackupPathFor(home, xlsx.absolutePath) + assertTrue(backup.exists(), "expected a backup at $backup") + assertTrue(originalBytes.contentEquals(backup.readBytes()), "the backup must hold the PRE-write bytes") + } + + @Test + fun `a fresh mutation against a comment never previously listed in this process still succeeds`() = runTest { + // Review 3, F1's own cold-start case, ported: no prior list()/read + // call in this test at all before the mutation — the id is looked up + // fresh, straight off the file. + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val knownG12Id = buildXlsxThreadIdForTest(1, "G12", "{3A26E9AE-8B38-864D-BAF4-BA5D9C6E1DA4}") + val result = resolveXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", knownG12Id, home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + } + + // ── F1 (High, android-xlsx-review) — untouched entries are streamed, ── + // ── never buffered whole; content survives exactly ───────────────── + + @Test + fun `an untouched STORED entry (e_g_ an embedded image) passes through RAW, byte-identical and still STORED, never re-compressed`() = runTest { + val base = fixtureFile("docling-xlsx-comments.xlsx") + val imageBytes = ByteArray(2 * 1024 * 1024) { (it % 251).toByte() } // 2MB, non-trivial pattern + val xlsx = withAddedStoredZipEntry(base, "xl/media/image1.png", imageBytes) + val home = tempHome() + + val result = addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("C1"), "x", "user", home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + + ZipFile(xlsx).use { zip -> + val entry = zip.getEntry("xl/media/image1.png") ?: fail("expected the untouched image entry to survive the write") + // The fix's own raw-passthrough case: a STORED entry this + // operation never touches must come back STILL STORED (never + // re-encoded to DEFLATED) — the old buggy code fully decompressed + // then rewrote EVERY entry via `zos.writeEntry` (always DEFLATED), + // so this specific assertion would fail against the pre-fix code. + assertEquals(ZipEntry.STORED, entry.method, "an untouched STORED entry must never be re-compressed") + assertEquals(imageBytes.size.toLong(), entry.size) + val outBytes = zip.getInputStream(entry).use { it.readBytes() } + assertTrue(imageBytes.contentEquals(outBytes), "untouched entry content must be byte-identical") + } + } + + @Test + fun `a workbook carrying a large (40MB declared) untouched part writes successfully and preserves its content exactly`() = runTest { + // A JVM unit test has no reliable way to assert peak heap directly — + // this proves the EXTERNALLY OBSERVABLE contract the fix promises + // (a large declared-size untouched part survives a comment write, + // content intact) rather than instrumenting the allocator. Combined + // with the STORED-passthrough test above (which DOES distinguish + // "streamed through" from "fully buffered then rewritten" via the + // compression-method assertion), this is the achievable proof in a + // plain `./gradlew test` environment. + val base = fixtureFile("docling-xlsx-comments.xlsx") + val largeBytes = ByteArray(40 * 1024 * 1024) // all-zero — deflates to almost nothing on disk + val xlsx = withAddedDeflatedZipEntry(base, "xl/media/image2.bin", largeBytes) + val home = tempHome() + + val result = addXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", cellSelector("D1"), "y", "user", home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + + ZipFile(xlsx).use { zip -> + val entry = zip.getEntry("xl/media/image2.bin") ?: fail("expected the untouched large entry to survive the write") + val outBytes = zip.getInputStream(entry).use { it.readBytes() } + assertEquals(largeBytes.size, outBytes.size) + assertTrue(largeBytes.contentEquals(outBytes), "untouched large entry content must be byte-identical") + } + + // The pre-existing F7/G12 threads must also survive untouched. + val read = readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok + assertEquals(1, commentAt(read.comments, "F7").size) + assertEquals(1, commentAt(read.comments, "G12").size) + } + + // ── F2 (Medium, android-xlsx-review) — the three desktop-review fixes ── + // ── ported into Kotlin with no dedicated pinning test of their own ───── + + @Test + fun `moving a thread twice within the same worksheet reuses its tc author entry, never duplicating it`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val f7 = commentAt(before, "F7").single() + val guid = parseXlsxThreadId(f7.id)!!.guid + + val move1 = moveXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", f7.id, cellSelector("M1"), home) + assertTrue(move1 is XlsxWriteResult.Ok, "expected Ok, got $move1") + val idAfterMove1 = (move1 as XlsxWriteResult.Ok).value + + val move2 = moveXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", idAfterMove1, cellSelector("N1"), home) + assertTrue(move2 is XlsxWriteResult.Ok, "expected Ok, got $move2") + + val commentsXml = readZipEntryText(xlsx, "xl/comments1.xml")!! + // Desktop review F2, ported (`resolveOrAppendTcAuthor`): moving a + // thread twice within the SAME worksheet must reuse its ONE + // `tc={GUID}` entry, never mint a byte-duplicate + // orphan on every move. + val authorCount = Regex("tc=\\{$guid\\}", RegexOption.IGNORE_CASE).findAll(commentsXml).count() + assertEquals(1, authorCount, "expected exactly one tc= author entry for this thread's GUID after two moves, found $authorCount") + } + + @Test + fun `declares xmlns_r defensively before stamping legacyDrawing on a worksheet that never had it`() = runTest { + val base = fixtureFile("synthetic-worksheet-with-extlst.xlsx") + // Neither real fixture (docling, elden) omits xmlns:r on its + // worksheet root — this is a hand-built minimal worksheet, the same + // "unusual/minimal writer" shape the desktop review's own F5 finding + // named as unverified-but-unguarded before its fix. + val minimalWorksheet = "" + + "" + + "1" + val xlsx = withReplacedZipEntry(base, "xl/worksheets/sheet1.xml", minimalWorksheet.toByteArray(Charsets.UTF_8)) + val home = tempHome() + + val result = addXlsxComment(xlsx.absolutePath, "reports/no-xmlns-r.xlsx", cellSelector("A1"), "first comment", "user", home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + + val worksheetXml = readZipEntryText(xlsx, "xl/worksheets/sheet1.xml")!! + assertTrue( + worksheetXml.contains("xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\""), + "must defensively declare xmlns:r before stamping legacyDrawing's r:id (desktop review F5, ported)", + ) + assertTrue(worksheetXml.contains(" so it no longer + // matches its own vmlDrawing relationship's Id — the SAME "partial/ + // inconsistent legacy wiring" shape `getWorksheetContext` already + // treats as ambiguous for a HINTED sheet. Desktop review F6, ported: + // the FALLBACK scan must surface the honest `ambiguous-comment-wiring` + // refusal instead of misreporting "comment not found" when it has to + // pass through this sheet on its way to searching every other one. + val originalWorksheetXml = readZipEntryText(xlsx, "xl/worksheets/sheet1.xml")!! + val corrupted = Regex("( + "${m.groupValues[1]}${m.groupValues[2]}ZZZ${m.groupValues[3]}" + } + assertTrue(corrupted != originalWorksheetXml, "the fixture must actually have a legacyDrawing r:id to corrupt") + val modified = withReplacedZipEntry(xlsx, "xl/worksheets/sheet1.xml", corrupted.toByteArray(Charsets.UTF_8)) + + // A bogus sheetId (no such sheet) + a GUID matching nothing, so the + // hinted lookup misses immediately and the fallback scan must walk + // every sheet — including the now-corrupted sheet1 — before + // concluding. + val bogusId = "xt-999-A1-00000000-0000-0000-0000-000000000000" + val result = resolveXlsxComment(modified.absolutePath, "reports/elden.xlsx", bogusId, tempHome()) + assertEquals(XlsxWriteError.AMBIGUOUS_COMMENT_WIRING, (result as XlsxWriteResult.Err).error) + } + + @Test + fun `allows a read exactly AT the record-count ceiling`() { + val base = fixtureFile("docling-xlsx-comments.xlsx") + // MAX_COMMENT_RECORDS (private in XlsxComments.kt) mirrors desktop's + // own 20000 — hardcoded here the same way the existing "+1 refuses" + // test does, rather than exporting an implementation constant purely + // for a test to read. Complements that test: an off-by-one in the + // `>` vs. `>=` comparison would only be caught by having BOTH + // boundaries tested. + val exactlyAtCeiling = buildString { + append("") + append("") + repeat(20000) { i -> append("x") } + append("") + } + val modified = withReplacedZipEntry(base, "xl/threadedComments/threadedComment1.xml", exactlyAtCeiling.toByteArray(Charsets.UTF_8)) + val result = readXlsxComments(modified, "reports/exactly.xlsx") + assertTrue(result is XlsxReadResult.Ok, "expected Ok exactly AT the ceiling, got $result") + assertEquals(20000, (result as XlsxReadResult.Ok).comments.size) + } + + // ── Edit/delete build (2026-09-28, design doc §"Edit and delete") ─────── + // Mirrors desktop's own xlsx-comments.test.ts `describe('xlsx-comments — + // edit', ...)` / `describe('xlsx-comments — delete (whole thread)', ...)` + // / `describe('xlsx-comments — delete-reply', ...)` blocks. F7 has a + // root+1 reply thread; G12 is root-only; A1/B2 are genuine Notes. + + @Test + fun `editXlsxComment overwrites the ROOT thread's text and rebuilds the legacy placeholder, leaving ref dT personId id untouched`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val f7 = commentAt(before, "F7").single() + + val result = editXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", f7.id, "Rewritten root.", home) + assertEquals(XlsxWriteResult.Ok("Rewritten root."), result) + + val read = readXlsxComments(xlsx, "reports/docling.xlsx") + assertTrue(read is XlsxReadResult.Ok, "expected Ok, got $read") + val edited = (read as XlsxReadResult.Ok).comments.find { it.id == f7.id } + assertNotNull(edited) + assertEquals("Rewritten root.", edited.text) + assertEquals(f7.author, edited.author) // person/date untouched + assertEquals(f7.createdAt, edited.createdAt) + assertEquals(f7.selector, edited.selector) + assertEquals(f7.replies.map { it.text }, edited.replies.map { it.text }) + + // §4.2: the placeholder is rebuilt whole from the CURRENT transcript. + val commentsXml = readZipEntryText(xlsx, "xl/comments1.xml")!! + assertTrue(commentsXml.contains("Comment:\n Rewritten root.")) + } + + @Test + fun `editXlsxComment refuses comment-not-found for an id with a well-formed shape but no matching thread`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val result = editXlsxComment( + xlsx.absolutePath, "reports/docling.xlsx", "xt-1-Z99-00000000-0000-0000-0000-000000000000", "x", tempHome(), + ) + assertEquals(XlsxWriteResult.Err(XlsxWriteError.COMMENT_NOT_FOUND), result) + } + + @Test + fun `editXlsxReply overwrites one reply and returns the persisted CommentReply, leaving the root untouched`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val f7 = commentAt(before, "F7").single() + val reply = f7.replies[0] + + val result = editXlsxReply(xlsx.absolutePath, "reports/docling.xlsx", f7.id, reply.id, "Edited reply.", home) + assertTrue(result is XlsxWriteResult.Ok, "expected Ok, got $result") + assertEquals( + CommentReply(reply.id, reply.author, "Edited reply.", reply.createdAt), + (result as XlsxWriteResult.Ok).value, + ) + + val read = readXlsxComments(xlsx, "reports/docling.xlsx") + assertTrue(read is XlsxReadResult.Ok, "expected Ok, got $read") + val parent = (read as XlsxReadResult.Ok).comments.find { it.id == f7.id } + assertNotNull(parent) + assertEquals(f7.text, parent.text) // root untouched + assertEquals(listOf(reply.copy(text = "Edited reply.")), parent.replies) + } + + @Test + fun `editXlsxReply refuses an unknown replyId`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val f7 = commentAt(before, "F7").single() + val result = editXlsxReply(xlsx.absolutePath, "reports/docling.xlsx", f7.id, "${f7.id}-r9", "x", tempHome()) + assertEquals(XlsxWriteResult.Err(XlsxWriteError.COMMENT_NOT_FOUND), result) + } + + @Test + fun `deleteXlsxComment removes the root plus its reply and the ONE legacy placeholder, leaving G12 and the genuine Notes untouched`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val f7 = commentAt(before, "F7").single() + + val result = deleteXlsxComment(xlsx.absolutePath, "reports/docling.xlsx", f7.id, home) + assertEquals(XlsxWriteResult.Ok(Unit), result) + + val read = readXlsxComments(xlsx, "reports/docling.xlsx") + assertTrue(read is XlsxReadResult.Ok, "expected Ok, got $read") + val comments = (read as XlsxReadResult.Ok).comments + assertFalse(comments.any { it.id == f7.id }) + assertEquals(1, commentAt(comments, "G12").size) // G12's own independent thread survives untouched + + val commentsXml = readZipEntryText(xlsx, "xl/comments1.xml")!! + assertFalse(commentsXml.contains("F7")) // the deleted thread's own placeholder is gone + } + + @Test + fun `deleting the sheet's LAST comment removes the now-empty comment parts, their rels, content-type overrides and legacyDrawing`() = runTest { + val target = buildMinimalXlsx() + val home = tempHome() + ZipFile(target).use { zip -> assertNull(zip.getEntry("xl/comments1.xml")) } + + val added = addXlsxComment(target.absolutePath, "fresh.xlsx", cellSelector("A1"), "only comment", "user", home) + assertTrue(added is XlsxWriteResult.Ok, "expected Ok, got $added") + val newId = (added as XlsxWriteResult.Ok).value + ZipFile(target).use { zip -> + assertNotNull(zip.getEntry("xl/comments1.xml")) + assertNotNull(zip.getEntry("xl/drawings/vmlDrawing1.vml")) + assertNotNull(zip.getEntry("xl/threadedComments/threadedComment1.xml")) + } + + val deleted = deleteXlsxComment(target.absolutePath, "fresh.xlsx", newId, home) + assertEquals(XlsxWriteResult.Ok(Unit), deleted) + + ZipFile(target).use { zip -> + assertNull(zip.getEntry("xl/comments1.xml")) + assertNull(zip.getEntry("xl/drawings/vmlDrawing1.vml")) + assertNull(zip.getEntry("xl/threadedComments/threadedComment1.xml")) + } + + // No dangling rels/content-types/legacyDrawing pointing at the parts + // just removed — "exactly as if it never had comments on that sheet". + val worksheetXml = readZipEntryText(target, "xl/worksheets/sheet1.xml")!! + assertFalse(worksheetXml.contains("legacyDrawing")) + val relsXml = readZipEntryText(target, "xl/worksheets/_rels/sheet1.xml.rels")!! + assertFalse(relsXml.contains("comments1.xml")) + assertFalse(relsXml.contains("vmlDrawing1.vml")) + assertFalse(relsXml.contains("threadedComment1.xml")) + val contentTypesXml = readZipEntryText(target, "[Content_Types].xml")!! + assertFalse(contentTypesXml.contains("/xl/comments1.xml")) + assertFalse(contentTypesXml.contains("/xl/threadedComments/threadedComment1.xml")) + + // A re-read confirms this is not just an empty ceremony — the file is + // genuinely back to "no comments" as readXlsxComments itself sees it. + val reread = readXlsxComments(target, "fresh.xlsx") + assertEquals(XlsxReadResult.Ok(emptyList()), reread) + } + + @Test + fun `deleting one of FIVE independent threads on the same cell never disturbs its siblings`() = runTest { + val xlsx = fixtureFile("elden-ring-completionist-checklist.xlsx") + val home = tempHome() + val before = readXlsxComments(xlsx, "reports/elden.xlsx") + assertTrue(before is XlsxReadResult.Ok, "expected Ok, got $before") + val atB19 = commentAt((before as XlsxReadResult.Ok).comments, "B19") + assertEquals(5, atB19.size) + val victim = atB19[0] + val others = atB19.drop(1) + + val result = deleteXlsxComment(xlsx.absolutePath, "reports/elden.xlsx", victim.id, home) + assertEquals(XlsxWriteResult.Ok(Unit), result) + + val after = readXlsxComments(xlsx, "reports/elden.xlsx") + assertTrue(after is XlsxReadResult.Ok, "expected Ok, got $after") + val comments = (after as XlsxReadResult.Ok).comments + assertFalse(comments.any { it.id == victim.id }) + for (o in others) { + val stillThere = comments.find { it.id == o.id } + assertNotNull(stillThere) + assertEquals(o.text, stillThere.text) + assertEquals(o.replies.map { it.text }, stillThere.replies.map { it.text }) + } + } + + @Test + fun `deleteXlsxComment refuses comment-not-found for an id with a well-formed shape but no matching thread, leaving the file byte-identical`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val before = xlsx.readBytes() + val result = deleteXlsxComment( + xlsx.absolutePath, "reports/docling.xlsx", "xt-1-Z99-00000000-0000-0000-0000-000000000000", tempHome(), + ) + assertEquals(XlsxWriteResult.Err(XlsxWriteError.COMMENT_NOT_FOUND), result) + assertTrue(xlsx.readBytes().contentEquals(before)) + } + + @Test + fun `deleteXlsxReply removes ONE reply and rebuilds the placeholder, leaving the root and its resolve state intact`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val home = tempHome() + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val f7 = commentAt(before, "F7").single() + val reply = f7.replies[0] + + val result = deleteXlsxReply(xlsx.absolutePath, "reports/docling.xlsx", f7.id, reply.id, home) + assertEquals(XlsxWriteResult.Ok(Unit), result) + + val read = readXlsxComments(xlsx, "reports/docling.xlsx") + assertTrue(read is XlsxReadResult.Ok, "expected Ok, got $read") + val root = (read as XlsxReadResult.Ok).comments.find { it.id == f7.id } + assertNotNull(root) // the thread itself survives + assertTrue(root.replies.isEmpty()) + assertEquals(f7.text, root.text) + + val commentsXml = readZipEntryText(xlsx, "xl/comments1.xml")!! + assertFalse(commentsXml.contains("Reply:")) // the placeholder was rebuilt with no reply block left + } + + @Test + fun `deleteXlsxReply refuses an unknown replyId`() = runTest { + val xlsx = fixtureFile("docling-xlsx-comments.xlsx") + val before = (readXlsxComments(xlsx, "reports/docling.xlsx") as XlsxReadResult.Ok).comments + val f7 = commentAt(before, "F7").single() + val result = deleteXlsxReply(xlsx.absolutePath, "reports/docling.xlsx", f7.id, "${f7.id}-r9", tempHome()) + assertEquals(XlsxWriteResult.Err(XlsxWriteError.COMMENT_NOT_FOUND), result) + } +} + +/** Edit/delete build (2026-09-28): a genuinely minimal, from-scratch .xlsx + * with NO comments of any kind — built by hand (Kotlin has no exceljs + * equivalent), mirroring desktop's own `writeMinimalXlsxTo` test helper + * (xlsx-comments.test.ts). Used ONLY for the "sheet's last comment removed + * → parts cleaned up" positive test, which needs a genuine zero-comments + * starting point to prove the parts are truly GONE afterward, not merely + * unchanged. */ +private fun buildMinimalXlsx(): File { + val dir = Files.createTempDirectory("ycd-xlsx-minimal-").toFile() + dir.deleteOnExit() + val target = File(dir, "fresh.xlsx") + ZipOutputStream(target.outputStream()).use { zos -> + fun entry(name: String, content: String) { + zos.putNextEntry(ZipEntry(name)) + zos.write(content.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + entry( + "[Content_Types].xml", + """""", + ) + entry( + "_rels/.rels", + """""", + ) + entry( + "xl/workbook.xml", + """""", + ) + entry( + "xl/_rels/workbook.xml.rels", + """""", + ) + entry( + "xl/worksheets/sheet1.xml", + """""", + ) + } + return target +} + +// Test-only helper mirroring `buildXlsxThreadId`'s own (private) shape — +// duplicated rather than exposed, since only this one cold-start test needs +// to construct an id from raw parts rather than reading one off a prior +// `readXlsxComments`/write call. +private fun buildXlsxThreadIdForTest(sheetId: Int, cell: String, guidBraced: String): String = + "xt-$sheetId-$cell-${guidBraced.replace("{", "").replace("}", "")}" diff --git a/app/src/test/kotlin/com/youcoded/app/runtime/ClaudeCodeDocCommentsMcpTest.kt b/app/src/test/kotlin/com/youcoded/app/runtime/ClaudeCodeDocCommentsMcpTest.kt new file mode 100644 index 000000000..963e9102f --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/runtime/ClaudeCodeDocCommentsMcpTest.kt @@ -0,0 +1,157 @@ +package com.youcoded.app.runtime + +import com.youcoded.app.doccomments.DocCommentsMcpNames +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +/** + * The Android half of attaching YouCoded's document-comment tools (T9c) to a + * Claude Code session — mirrors ClaudeCodeMcpTest.kt's own shape for the + * SendUserLink precedent this task was scoped against. Also pins the T9c/T20 + * adversarial review's findings #1 (owner-only permissions), #2 (no fixed + * fallback directory) and #3 (leftover-deploy sweep). + */ +class ClaudeCodeDocCommentsMcpTest { + + @get:Rule + val tmp = TemporaryFolder() + + @After + fun tearDown() { + // Never let one test's sweep-triggering leak into another's + // expectations about which call swept what. + ClaudeCodeDocCommentsMcp.resetSweepForTest() + } + + @Test + fun `config runs the server through linker64, not bare node, with the per-session env`() { + val json = JSONObject( + ClaudeCodeDocCommentsMcp.configJson( + "/system/bin/linker64", "/data/usr/bin/node", "/data/x/doc-comments-mcp.js", + "youcoded-doc-comments-deadbeef", "/sdcard/project", "secret-token", + ), + ) + val server = json.getJSONObject("mcpServers").getJSONObject("youcoded-doc-comments-deadbeef") + assertEquals("stdio", server.getString("type")) + assertEquals("/system/bin/linker64", server.getString("command")) + assertEquals("/data/usr/bin/node", server.getJSONArray("args").getString(0)) + assertEquals("/data/x/doc-comments-mcp.js", server.getJSONArray("args").getString(1)) + val env = server.getJSONObject("env") + assertEquals("/sdcard/project", env.getString(ClaudeCodeDocCommentsMcp.PROJECT_ROOT_ENV)) + assertEquals("secret-token", env.getString(ClaudeCodeDocCommentsMcp.TOKEN_ENV)) + } + + @Test + fun `deploy writes both files and returns only the read tool as allowed`() { + val dir = File(tmp.root, ".claude-mobile") + val deployment = ClaudeCodeDocCommentsMcp.deploy( + dir, "// server source\n", "/system/bin/linker64", "/data/usr/bin/node", "/sdcard/project", + ) + assertNotNull(deployment) + deployment!! + + val serverFile = File(deployment.configPath).parentFile!!.resolve(ClaudeCodeDocCommentsMcp.SERVER_FILE) + assertTrue(serverFile.exists()) + assertEquals("// server source\n", serverFile.readText()) + assertTrue(File(deployment.configPath).exists()) + assertEquals(File(deployment.configPath).parentFile!!.absolutePath, deployment.deployDir) + + assertEquals(listOf(DocCommentsMcpNames.readTool(deployment.serverId)), deployment.allowedTools) + // Never one of the five mutation tools — see this object's own header. + for (mutator in DocCommentsMcpNames.mutatorTools(deployment.serverId)) { + assertTrue(mutator !in deployment.allowedTools) + } + } + + @Test + fun `two deployments from the same call site get different server ids, tokens and directories`() { + val dir = File(tmp.root, ".claude-mobile") + val a = ClaudeCodeDocCommentsMcp.deploy(dir, "// src\n", "/system/bin/linker64", "/node", "/proj")!! + val b = ClaudeCodeDocCommentsMcp.deploy(dir, "// src\n", "/system/bin/linker64", "/node", "/proj")!! + assertNotEquals(a.serverId, b.serverId) + assertNotEquals(a.token, b.token) + assertNotEquals(a.configPath, b.configPath) + assertNotEquals(a.deployDir, b.deployDir) + // Both directories genuinely exist side by side — a second deploy + // never overwrites or displaces the first's still-live directory. + assertTrue(File(a.deployDir).exists()) + assertTrue(File(b.deployDir).exists()) + } + + @Test + fun `no fixed fallback directory exists to reintroduce a collision (finding #2)`() { + // deploy() takes no session-id-shaped parameter at all any more — the + // ONLY thing that can name a deployment's directory is the serverId + // this function mints internally, which is always fresh. Confirmed + // structurally: ten deployments to the same baseDir land in ten + // distinct, simultaneously-existing directories, never a shared one. + val dir = File(tmp.root, ".claude-mobile") + val deployments = (1..10).map { + ClaudeCodeDocCommentsMcp.deploy(dir, "// src\n", "/system/bin/linker64", "/node", "/proj")!! + } + assertEquals(10, deployments.map { it.deployDir }.toSet().size) + for (d in deployments) assertTrue(File(d.deployDir).exists()) + } + + @Test + fun `the deploy directory and config file are owner-only, finding #1 cheap hardening`() { + val dir = File(tmp.root, ".claude-mobile") + val deployment = ClaudeCodeDocCommentsMcp.deploy(dir, "// src\n", "/system/bin/linker64", "/node", "/proj")!! + + val dirPerms = Files.getPosixFilePermissions(File(deployment.deployDir).toPath()) + val configPerms = Files.getPosixFilePermissions(File(deployment.configPath).toPath()) + + val ownerOnly = setOf( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE, + ) + val groupOrOther = PosixFilePermission.entries.filter { it !in setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE) } + + assertTrue("deploy dir must not be readable/writable/executable by group or other, got $dirPerms", groupOrOther.none { it in dirPerms }) + assertTrue("config file must not be readable/writable/executable by group or other, got $configPerms", groupOrOther.none { it in configPerms }) + // And the owner itself can still read/write both, obviously. + assertTrue(PosixFilePermission.OWNER_READ in dirPerms && PosixFilePermission.OWNER_WRITE in dirPerms) + assertTrue(PosixFilePermission.OWNER_READ in configPerms && PosixFilePermission.OWNER_WRITE in configPerms) + assertFalse(ownerOnly.isEmpty()) // sanity: the constant above is non-trivial + } + + @Test + fun `a leftover directory from a previous process run is swept on the first deploy of a fresh process`() { + val dir = File(tmp.root, ".claude-mobile") + // Simulate litter left behind by a crashed/killed earlier process — + // never cleaned up because PtyBridge.stop() never ran for it. + val leftover = File(dir, "claude-code-doc-comments-mcp/youcoded-doc-comments-oldstale") + leftover.mkdirs() + File(leftover, "mcp-config.json").writeText("""{"mcpServers":{}}""") + assertTrue(leftover.exists()) + + ClaudeCodeDocCommentsMcp.resetSweepForTest() // pretend this is a fresh process + val deployment = ClaudeCodeDocCommentsMcp.deploy(dir, "// src\n", "/system/bin/linker64", "/node", "/proj")!! + + assertFalse("a leftover directory from a previous process must be swept", leftover.exists()) + // The CURRENT deployment's own directory survives the same sweep. + assertTrue(File(deployment.deployDir).exists()) + } + + @Test + fun `the sweep never touches a sibling deployment still live in the SAME process`() { + val dir = File(tmp.root, ".claude-mobile") + ClaudeCodeDocCommentsMcp.resetSweepForTest() + val first = ClaudeCodeDocCommentsMcp.deploy(dir, "// src\n", "/system/bin/linker64", "/node", "/proj")!! + // A second deploy in the SAME process must never re-sweep (the flag + // is now set) — it must not delete the first deployment's directory. + val second = ClaudeCodeDocCommentsMcp.deploy(dir, "// src\n", "/system/bin/linker64", "/node", "/proj")!! + assertTrue(File(first.deployDir).exists()) + assertTrue(File(second.deployDir).exists()) + } +} diff --git a/app/src/test/resources/doc-comments/chartsheet-workbook.xlsx b/app/src/test/resources/doc-comments/chartsheet-workbook.xlsx new file mode 100644 index 000000000..7355f4c77 Binary files /dev/null and b/app/src/test/resources/doc-comments/chartsheet-workbook.xlsx differ diff --git a/app/src/test/resources/doc-comments/deeply-nested.docx b/app/src/test/resources/doc-comments/deeply-nested.docx new file mode 100644 index 000000000..9df811a28 Binary files /dev/null and b/app/src/test/resources/doc-comments/deeply-nested.docx differ diff --git a/app/src/test/resources/doc-comments/docling-xlsx-comments.xlsx b/app/src/test/resources/doc-comments/docling-xlsx-comments.xlsx new file mode 100644 index 000000000..cd76b932b Binary files /dev/null and b/app/src/test/resources/doc-comments/docling-xlsx-comments.xlsx differ diff --git a/app/src/test/resources/doc-comments/elden-ring-completionist-checklist.xlsx b/app/src/test/resources/doc-comments/elden-ring-completionist-checklist.xlsx new file mode 100644 index 000000000..28bb62c78 Binary files /dev/null and b/app/src/test/resources/doc-comments/elden-ring-completionist-checklist.xlsx differ diff --git a/app/src/test/resources/doc-comments/fresh-single-comment.xlsx b/app/src/test/resources/doc-comments/fresh-single-comment.xlsx new file mode 100644 index 000000000..df69a2437 Binary files /dev/null and b/app/src/test/resources/doc-comments/fresh-single-comment.xlsx differ diff --git a/app/src/test/resources/doc-comments/golden/chartsheet-workbook.json b/app/src/test/resources/doc-comments/golden/chartsheet-workbook.json new file mode 100644 index 000000000..a0d0268f8 --- /dev/null +++ b/app/src/test/resources/doc-comments/golden/chartsheet-workbook.json @@ -0,0 +1,3 @@ +{ + "comments": [] +} diff --git a/app/src/test/resources/doc-comments/golden/deeply-nested.json b/app/src/test/resources/doc-comments/golden/deeply-nested.json new file mode 100644 index 000000000..dadde2732 --- /dev/null +++ b/app/src/test/resources/doc-comments/golden/deeply-nested.json @@ -0,0 +1,24 @@ +{ + "comments": [ + { + "id": "w-9", + "path": "docs/deeply-nested.docx", + "selector": { + "kind": "text", + "selector": { + "type": "TextQuoteSelector", + "exact": "A comment buried very deep in the document tree.", + "prefix": "", + "suffix": " ", + "occurrence": 0 + } + }, + "text": "A comment buried very deep in the document tree.", + "author": "person:Priya Shah", + "createdAt": 1790413800000, + "replies": [], + "resolved": false, + "history": [] + } + ] +} diff --git a/app/src/test/resources/doc-comments/golden/docling-xlsx-comments.json b/app/src/test/resources/doc-comments/golden/docling-xlsx-comments.json new file mode 100644 index 000000000..070d9d8e4 --- /dev/null +++ b/app/src/test/resources/doc-comments/golden/docling-xlsx-comments.json @@ -0,0 +1,45 @@ +{ + "comments": [ + { + "id": "xt-1-F7-04C1C54B-2744-A647-93D1-A99C27C7EFDC", + "path": "reports/docling-xlsx-comments.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F7" + } + }, + "text": "Minimum number of saltwater ducks", + "author": "person:Jane Smith (JS)", + "createdAt": 1781827957410, + "replies": [ + { + "id": "xt-1-F7-04C1C54B-2744-A647-93D1-A99C27C7EFDC-r1", + "author": "person:Marcus Sterling (MS)", + "text": "I never thought it would be so low", + "createdAt": 1781828152310 + } + ], + "resolved": false, + "history": [] + }, + { + "id": "xt-1-G12-3A26E9AE-8B38-864D-BAF4-BA5D9C6E1DA4", + "path": "reports/docling-xlsx-comments.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G12" + } + }, + "text": "Maximum number of ducks", + "author": "person:Jane Smith (JS)", + "createdAt": 1781827977350, + "replies": [], + "resolved": false, + "history": [] + } + ] +} diff --git a/app/src/test/resources/doc-comments/golden/elden-ring-completionist-checklist.json b/app/src/test/resources/doc-comments/golden/elden-ring-completionist-checklist.json new file mode 100644 index 000000000..52d066d4a --- /dev/null +++ b/app/src/test/resources/doc-comments/golden/elden-ring-completionist-checklist.json @@ -0,0 +1,13024 @@ +{ + "comments": [ + { + "id": "xt-1-B19-36100e21-63dc-459d-8552-6830736c717e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B19", + "sheet": "Boss List" + } + }, + "text": "✓", + "author": "person:Devin Stock (Friedfrisbe)", + "createdAt": 1651044232000, + "replies": [ + { + "id": "xt-1-B19-36100e21-63dc-459d-8552-6830736c717e-r1", + "author": "person:Devin Stock (Friedfrisbe)", + "text": "Marked as resolved", + "createdAt": 1651044324000 + }, + { + "id": "xt-1-B19-36100e21-63dc-459d-8552-6830736c717e-r2", + "author": "person:Adithya Chavan", + "text": "To whoever took the time to make this, You're a Gem!\nRe-opened", + "createdAt": 1651163308000 + }, + { + "id": "xt-1-B19-36100e21-63dc-459d-8552-6830736c717e-r3", + "author": "person:Jacob Bacub", + "text": "Thank you kind sir!\nMarked as resolved", + "createdAt": 1651200718000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-d7a920d0-7295-4a56-a19b-e97e2818d6dd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Evan Sobie", + "createdAt": 1653536831000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-9feccb93-dc66-4551-a125-057a68152758", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "/", + "author": "person:Luke Pennington", + "createdAt": 1659923585000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F9-166a2bc5-db6c-4c17-a475-f1c74515adda", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F9", + "sheet": "Boss List" + } + }, + "text": "Done", + "author": "person:Tinka Tinka", + "createdAt": 1667257190000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-9989e83c-57e1-4a2e-9f33-565df8ad9355", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673687291000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B3-02e426ff-8c8c-4365-9af4-50e80918084b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B3", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673688397000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N27-fa72f537-03de-42a1-a0fc-a538572ba8fa", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N27", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Corey Mcl", + "createdAt": 1684836811000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-4b18c1a2-8ade-43a3-a2ab-21506e74b09e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "1", + "author": "person:Connor", + "createdAt": 1651452451000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N37-f829b4dd-9389-46eb-876b-c09173f0c7ef", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N37", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719696000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N25-242f0fbb-e0be-4094-b576-08c8bcdff44c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N25", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720366000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N36-a71a0bd1-49d1-4a89-b21f-d9ef1c0bad3e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N36", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719688000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B12-00b373df-64c5-4276-9130-9b612a171463", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B12", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673690260000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N34-5c5f85d9-f2fa-4ca3-916c-f7dc65cb185c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N34", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719674000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B13-8964bb95-2343-4dbb-a8f9-cb0e3555c7d3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B13", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673690689000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B15-b536275f-9518-4aeb-89bc-1b8a4cdd6677", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B15", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673736829000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N33-9f581d93-f3ff-439d-b8c8-a6267fcf6208", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N33", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719666000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B16-173bbe42-ee57-4800-b124-89348ad7393f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B16", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673690705000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N6-73383fc4-3f01-4d02-aaa1-6f66e37f0ee5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N6", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719647000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B9-2b6847ce-06e2-4be6-a788-b7cd37b1a2a2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B9", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673690146000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B10-d1168a62-d9de-4482-be0b-ffbe36f2f098", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B10", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673690243000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B11-cefa86e0-a421-4466-9564-fb712119d6e5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B11", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673690251000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N11-fed4328f-6aca-43df-8992-4a808f3a394a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N11", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720196000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N10-fc258a73-fc72-4838-beae-c5b7f4317e71", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N10", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720185000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B4-b53b9705-4f8a-4475-ac5f-e511b5d2b341", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B4", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673688747000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N8-4671e599-2705-4a3e-a82e-283049d2eb7d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N8", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720170000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B5-17d7b970-ea62-481c-ab0c-5a9c53494c33", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B5", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673688759000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B6-444220b0-53de-41d0-a8d7-ea7fa9a089fc", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B6", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673689461000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B8-f5010f5f-14f6-474e-96b5-0b5b199f7a0b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B8", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673689499000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B4-8146ac01-2972-429a-a6e8-341c8c00c061", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B4", + "sheet": "Boss List" + } + }, + "text": "Done", + "author": "person:Shareece Horsburgh", + "createdAt": 1660717506000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N22-57c84426-88bd-46d0-a716-376ff9497c87", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N22", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720339000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N21-5d85c551-423b-4dde-b478-0efe75915350", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N21", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720323000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N20-66a57e10-5ab6-4b83-bf86-cecee08a732e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N20", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720314000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B3-368d9c7d-bc38-47c1-972e-1c9ef12b985d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B3", + "sheet": "Boss List" + } + }, + "text": "ok", + "author": "person:Fotis Ath", + "createdAt": 1723402119000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N19-b2d95bc3-b17b-4d62-b342-3369635117ee", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N19", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720306000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N15-dcd30070-701d-4aeb-b2a2-00f188faeeaa", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N15", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720285000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N35-46e7017f-2266-4afe-b159-54e59be3c8ec", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N35", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720138000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N16-9b4a681a-8956-42e9-86a9-6eebbd13044f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N16", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720271000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N57-cbe96615-28d3-424a-bf93-d345299adb84", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N57", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719817000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N17-13ab4d18-717a-407a-829c-4622fda76773", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N17", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720262000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N43-8f74d2b5-a7ea-4e39-8026-cde2ab717df2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N43", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719766000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N18-ac88ff77-915b-4729-8d82-100936ff02ee", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N18", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720253000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N42-14e25eeb-82f8-499f-acf5-005333a367f6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N42", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719760000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-b62cb87c-7050-4cc6-8930-ca0a3b800923", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "x", + "author": "person:Anonymous", + "createdAt": 1722198372000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N14-284c5186-6271-43d9-9b45-150e1b545091", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N14", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720230000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N41-e0863453-dbea-4d12-b648-c697e518b849", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N41", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719752000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N13-2a62dee4-11fa-4635-ac98-5110862b2443", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N13", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720221000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N40-e7fd2541-2197-4a38-a571-b97d4c7af959", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N40", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719742000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N12-c71fa4ef-bfb2-4c85-aba6-611aedf09460", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N12", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720212000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N39-f93341a6-e7e7-4685-8b02-5012e3270b69", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N39", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719721000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N27-238c17c7-02b4-42aa-8530-a22593c11250", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N27", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720392000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N38-759bb81c-9b19-402f-8353-6811e277722c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N38", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719713000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N26-2c12bea0-bbc4-44f5-b190-9cfb8d55b92e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N26", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720383000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B52-6bc66d1c-8a2b-4c00-8fe7-08ed7dace838", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B52", + "sheet": "Boss List" + } + }, + "text": "C", + "author": "person:Sean Lichanec", + "createdAt": 1656663585000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B19-771ed205-e617-451f-9192-f28735894165", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B19", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662655000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B51-e5e2f57b-5dea-4134-aac5-07ec70b4d0b5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B51", + "sheet": "Boss List" + } + }, + "text": "C", + "author": "person:Sean Lichanec", + "createdAt": 1656663576000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B18-a920549d-5d51-4a40-ae94-ae4dc1227519", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B18", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662574000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B17-065a398a-f1d0-4b32-b86a-dd7b9a511350", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B17", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662554000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B16-428e1a7c-7da9-4bfb-9f38-b439b5044f2f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B16", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662533000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B4-eeaacfcf-b2e6-41e4-91db-984b3f4bfa70", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B4", + "sheet": "Boss List" + } + }, + "text": "j", + "author": "person:Jas", + "createdAt": 1697365143000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B15-c3f9ddce-eb08-44cc-8084-f4c9bd7622ac", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B15", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662522000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B7-d799a837-0e09-4d09-9543-8153a8373acf", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B7", + "sheet": "Boss List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652846026000, + "replies": [ + { + "id": "xt-1-B7-d799a837-0e09-4d09-9543-8153a8373acf-r1", + "author": "person:Senna Thorns", + "text": "!", + "createdAt": 1653166565000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B36-d27bc0dd-2758-46a9-8543-853660c16790", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B36", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663014000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B35-660dbecb-c01c-4d97-a51d-cc578ebc8f83", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B35", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663004000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-d6070496-4cfd-49d5-b9ad-14704adc3695", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "s", + "author": "person:Julio Matheus", + "createdAt": 1689665492000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N24-3f1dac95-25ea-458e-af83-e7bc097f3a58", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N24", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720357000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N23-7a2817af-623a-4095-b130-092048c93e49", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N23", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656720348000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B14-282d8c3c-703f-431f-ba57-8c123136510f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B14", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662457000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B47-605aec34-bda3-4466-a042-fc588a8e4997", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B47", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663536000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B20-4281df5c-5f6f-4cd6-8327-de6c7b5be3a4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B20", + "sheet": "Boss List" + } + }, + "text": "Done", + "author": "person:Peter Roberts", + "createdAt": 1651201236000, + "replies": [ + { + "id": "xt-1-B20-4281df5c-5f6f-4cd6-8327-de6c7b5be3a4-r1", + "author": "person:Jacob Bacub", + "text": "Select \"File\" in the top left corner of your screen, then select \"Make a copy\". Select OK and then you have your own copy that you can check off and edit all you like.\nMarked as resolved", + "createdAt": 1651201367000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B46-c0b3c56b-ec50-498a-bf3c-4bc515cd9ed4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B46", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663531000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-C2-c64e2641-0163-4cf5-904c-1bdfcee4c2ae", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C2", + "sheet": "Boss List" + } + }, + "text": "Done", + "author": "person:Connor Heaphy", + "createdAt": 1651789493000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B44-1bcb6e20-4ec6-4eb7-b7e2-79550089781a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B44", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663522000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B45-5aae4344-9f74-4064-adfd-ae0cc54a63c8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B45", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663303000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B56-035d5ba1-0856-46d0-bdb6-003331b8e9f4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B56", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663613000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B43-7428d245-2a8e-4883-8435-d90af6b44c10", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B43", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663289000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B20-9228e702-89d9-4543-a52f-3187562e3470", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B20", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652761961000, + "replies": [ + { + "id": "xt-1-B20-9228e702-89d9-4543-a52f-3187562e3470-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166678000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B55-8f955e8a-36bf-4d27-a5ef-c4cec8361595", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B55", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663608000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B19-e054cece-61ec-4749-b1f7-591001d872c5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B19", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652761983000, + "replies": [ + { + "id": "xt-1-B19-e054cece-61ec-4749-b1f7-591001d872c5-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166657000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B3-afae191d-f8e2-4ee7-b5b7-7f0f33a73105", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B3", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656646601000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B22-5dbd18a9-6e6f-4751-b6f9-ae6861d26456", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B22", + "sheet": "Boss List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725885195000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-6afc58ce-0bb9-4761-adef-096dc56f63cf", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656637913000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B42-f16d7073-524d-4b44-a8f5-092b97d289f8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B42", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663281000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B34-0529dda2-c9a9-4943-ace3-0f295ed16015", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B34", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662994000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B41-d2767266-1b47-447f-8776-86299d66e859", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B41", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663260000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B40-fc857e9a-66b2-4f4a-8135-ef7cf2183db3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B40", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663246000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B4-66670012-3a85-4a59-bdbb-9f7e400f728b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B4", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656646619000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B39-818a011d-efa4-437d-8ac1-330d955ba7fa", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B39", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663215000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B6-445c351d-49d6-4c56-acb9-0bf3f4adfafd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B6", + "sheet": "Boss List" + } + }, + "text": "x", + "author": "person:Sean Lichanec", + "createdAt": 1656648185000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B33-b78e6a8c-6ead-4684-9cc0-f217ce723945", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B33", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662808000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B38-a27b21b6-87d0-4fbb-b570-56cf3d3c8dfa", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B38", + "sheet": "Boss List" + } + }, + "text": "C", + "author": "person:Sean Lichanec", + "createdAt": 1656663205000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B5-e9eb455a-6193-4969-ac60-8c9b8c92eeac", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B5", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656648160000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B23-8996ae43-e1be-404e-8b28-bc906b21a2c3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B23", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662788000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B22-8b07abfa-33f1-4843-9336-18083e9e6293", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B22", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662782000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B10-e0cfbf56-a84c-4216-a266-45ce00266bd9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B10", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662047000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B37-f4f89524-8e39-4f55-be7a-7fe47c479533", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B37", + "sheet": "Boss List" + } + }, + "text": "C", + "author": "person:Sean Lichanec", + "createdAt": 1656663142000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B7-8fb3954c-bb33-4021-9258-e73be2fad2a2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B7", + "sheet": "Boss List" + } + }, + "text": "x", + "author": "person:Sean Lichanec", + "createdAt": 1656648200000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B12-fdaf4c32-f962-4eed-80bc-22820813fc5e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B12", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662078000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B21-c3f1d4f4-0283-4db4-a073-e86f3cf3ca9e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B21", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662763000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B54-2e9718d2-1e9f-480d-bac6-7e4b28a2f21c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B54", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663597000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B11-3145f58e-f9d7-4156-842e-9b6c3c9d97a3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B11", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662065000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B20-b695140c-adcb-4777-aa27-fd6ed20b2f4c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B20", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662754000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F35-2c41e0a0-e8be-455e-ae56-9e9adbe02f3b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F35", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:KG99", + "createdAt": 1689287923000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-99daa861-42e4-421e-85a4-6df38e5764f3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886656000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-3fbf2dd1-721e-40c4-89ef-b11db50c4d67", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "x", + "author": "person:Zachery Nevin", + "createdAt": 1657523106000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B5-1e0ebe57-0758-4bda-af8c-e905a5707a24", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B5", + "sheet": "Boss List" + } + }, + "text": "True", + "author": "person:Elisabeth Robin", + "createdAt": 1761775349000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-89e4a88c-8b10-4e8b-8e51-4b4a1813ca86", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "C", + "author": "person:Connor Heaphy", + "createdAt": 1651787890000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B57-d80229db-5122-4ed4-8fd2-5d8c33841ab7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B57", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663710000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-1cf7fbdb-715a-4e00-a039-0be993531e6d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "x", + "author": "person:Taylor Thomas", + "createdAt": 1712473314000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B53-e13ff7da-1f94-42eb-9e6e-138c8c42dbfd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B53", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663692000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B50-7fa110af-3a00-42c9-9d71-22ab80d9b939", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B50", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656663555000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B20-3a60ae67-6ee9-4f31-9959-b0996a238a97", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B20", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673738230000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B21-67386961-7e33-41b0-afaf-d0d6ab0a6806", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B21", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673738390000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-M56-677272dc-76ad-4ee0-92cd-8137c61e38fe", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "M56", + "sheet": "Boss List" + } + }, + "text": "Hello, really liking this spreadsheet but I'm just wondering why do some titles have -6 or -4 for example?", + "author": "person:Stephen Bielby (Bielbo)", + "createdAt": 1659879639000, + "replies": [ + { + "id": "xt-1-M56-677272dc-76ad-4ee0-92cd-8137c61e38fe-r1", + "author": "person:Jacob Bacub", + "text": "Sorry for the late response, I don't really check these anymore. The negative numbers are the underground areas. The negative numbers go down based on the difficulty of the area. The positive numbers are the overworld going up by difficulty.", + "createdAt": 1664007351000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B19-11a0fac8-69fe-49e4-a4d6-f19627c11856", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B19", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673737526000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B18-e82d0036-2010-4c65-a302-608e11144fcd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B18", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673736838000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B22-e7338efe-8a4d-4bde-9537-a20586e61c1d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B22", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673690737000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B23-28a5f0e1-bea4-4998-8d1c-a01048105afb", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B23", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673737479000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-9d6bb6d3-4915-4212-a0ee-62a783ccd440", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "x", + "author": "person:Jeff Grimm", + "createdAt": 1656999318000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-C33-247a7bbb-2e1c-4ca3-ac2b-fd76d61e9194", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C33", + "sheet": "Boss List" + } + }, + "text": "1", + "author": "person:Anonymous", + "createdAt": 1651911111000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B22-e90ac0f2-ec3e-4e7a-92d0-4b303c9eadd7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B22", + "sheet": "Boss List" + } + }, + "text": "A", + "author": "person:Marco Teixeira", + "createdAt": 1652761889000, + "replies": [ + { + "id": "xt-1-B22-e90ac0f2-ec3e-4e7a-92d0-4b303c9eadd7-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166664000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B21-696c75cb-ad31-47a7-998a-509ee44793e1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B21", + "sheet": "Boss List" + } + }, + "text": "S", + "author": "person:Marco Teixeira", + "createdAt": 1652761824000, + "replies": [ + { + "id": "xt-1-B21-696c75cb-ad31-47a7-998a-509ee44793e1-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166670000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-c1ae2c97-7895-4ee5-94cb-c512f81bdd22", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "z", + "author": "person:DreadSlayer97 (space_marine1993)", + "createdAt": 1733461070000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B14-c6af1107-711d-4d76-9200-4ef3851d0d48", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B14", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673736487000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F9-f829178d-ebb7-4c27-8b14-a2929abed515", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F9", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166737000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J19-63b68dc4-01bd-4f72-bb24-a73d3934d641", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J19", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166872000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B10-461bf0ec-cf69-494a-a724-09c511a92f56", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B10", + "sheet": "Boss List" + } + }, + "text": "J", + "author": "person:Guilherme Muraoka", + "createdAt": 1693715798000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F5-417bebb9-e99d-4fbe-a0a3-0cf7fb31fee1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F5", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166713000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J15-9f45c956-bbef-4b45-85eb-d51677976fbb", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J15", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166853000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N16-581b474e-4ffe-4562-9e6a-7bf753f15fe2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N16", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167249000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F11-3530ed22-d109-4555-95ca-a7be55482321", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F11", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166728000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-A22-3f1ac661-2f52-4fdd-910e-8f23d1457a2d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "A22", + "sheet": "Boss List" + } + }, + "text": "Killed", + "author": "person:Jeff Litz", + "createdAt": 1653018410000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J18-63e73f2a-582d-4506-bc7f-7456c566a3cc", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J18", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166866000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F38-9c57a449-4379-47a1-95a0-cdb56118f453", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F38", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167303000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J41-1dc001f7-0e51-49f7-91ee-45d7f96519c7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J41", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167061000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J26-354c6d0d-8105-4489-89cd-d2bd46eb660c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J26", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166897000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J57-68349621-177b-47c2-b1c3-683505c410ac", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J57", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167093000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J29-1cf5d248-db6b-4c99-89e8-7ca2ef9cd1ae", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J29", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166912000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-42bcce99-4576-45bb-9697-de073eece34c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "1", + "author": "person:BaToNaSss", + "createdAt": 1710470347000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J39-aa636fb3-4b1a-420f-80a5-9c6a784f4761", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J39", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167058000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-A12-2fc1678f-c040-4d12-8b3b-4df23335a260", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "A12", + "sheet": "Boss List" + } + }, + "text": "Killed", + "author": "person:Jeff Litz", + "createdAt": 1653017781000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J24-3ff2637a-c990-4ef9-8776-6a1d8d0ee0b4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J24", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166884000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J25-5d9e387a-e668-49c6-8357-69a46ebd8b9b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J25", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166891000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N36-7e8adf90-1dbf-428d-99f0-f4b0ac830394", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N36", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167135000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F39-64362380-5281-4dfb-a341-8dfb25a43949", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F39", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167315000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N54-02941e47-fc8b-4386-b21b-237876e8ffe8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N54", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167113000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J23-4a5eb5bd-e025-4d9f-8ab0-5a4915817045", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J23", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166919000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J22-aa9291ff-6335-46a9-befc-3bf6681bbd39", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J22", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166937000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N2-e84ada6b-75c7-4703-9dc4-f03636a6bdab", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N2", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167184000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B38-5fff35fc-cb92-4bec-818e-460ba0691e6d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B38", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167412000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N7-27efb1bd-8c0d-484c-a173-ee4b191c0d64", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N7", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167199000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B39-49dc09a6-b5ff-4593-8e46-898f9cb44bcc", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B39", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167432000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N27-87c57a73-ba9d-42fa-b001-d97d1bec30ea", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N27", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167153000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F42-a0fa08ea-894e-4184-b1b3-15e4f5d9e850", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F42", + "sheet": "Boss List" + } + }, + "text": "!", + "author": "person:Senna Thorns", + "createdAt": 1653167334000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N22-c572769b-c754-4ebc-b81d-b56cc7de6da5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N22", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167170000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F44-dbf3f801-8bc6-42db-ab4a-f6beb008d11f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F44", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167342000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B15-f252a2a0-1eae-4be9-a7e1-3376fde03ff0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B15", + "sheet": "Boss List" + } + }, + "text": "x", + "author": "person:krizz", + "createdAt": 1667974873000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B10-e84b3c60-c27e-4e1a-9e70-db552ff8fb6c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B10", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652762037000, + "replies": [ + { + "id": "xt-1-B10-e84b3c60-c27e-4e1a-9e70-db552ff8fb6c-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166610000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N8-fb8d4d44-c46e-405c-8691-a2430e56bac8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N8", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167209000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B41-8797b685-07dc-4c71-bbfe-0933ef594cad", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B41", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167434000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N10-f1b22d01-28a8-429c-bc89-8ce29d50a4d5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N10", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167215000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-3463e01f-4d1a-4fe8-bbea-f0c80ab523b7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "x", + "author": "person:Anonymous", + "createdAt": 1671179958000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B44-dba46634-7e8c-4645-8246-ac41b5175deb", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B44", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167466000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-A8-939c7bc4-c544-4cb0-bf25-301d4461f57b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "A8", + "sheet": "Boss List" + } + }, + "text": "Killed", + "author": "person:Jeff Litz", + "createdAt": 1653019118000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B46-8cdc1701-6914-412d-a189-bc295d3b18b2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B46", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167467000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B22-6dffde4a-cd65-45a6-8d3a-64db1519052c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B22", + "sheet": "Boss List" + } + }, + "text": "Tuer", + "author": "person:Osmose Vagabonde", + "createdAt": 1710801206000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B45-5d532b8f-c31a-46ab-8138-7eaeddf38a45", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B45", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167486000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B4-7f031a71-10d1-44ab-8532-b65f2ef77682", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B4", + "sheet": "Boss List" + } + }, + "text": "G", + "author": "person:KeanHopwood", + "createdAt": 1704511604000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-35d9d9a5-cb16-4375-b07d-1cde7f8fc99b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "x", + "author": "person:Jack Baker", + "createdAt": 1735342068000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B12-bf1436b8-0782-4bd9-aff6-b2fae698fd2a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B12", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652762022000, + "replies": [ + { + "id": "xt-1-B12-bf1436b8-0782-4bd9-aff6-b2fae698fd2a-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166620000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B13-14ba7bfb-d7d1-4c14-88ad-c36cc41cb7ab", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B13", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652762011000, + "replies": [ + { + "id": "xt-1-B13-14ba7bfb-d7d1-4c14-88ad-c36cc41cb7ab-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166626000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-38b1dc0c-3dc2-469c-9de2-f1f24222bf37", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "Check", + "author": "person:GHOSTLORD 111", + "createdAt": 1658019927000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F7-1aba7afd-3404-4414-b88c-7aec1a9e55a4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F7", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656681357000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F44-35d737fb-7ecd-4bf3-9d36-65c8bee70281", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F44", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682341000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F42-45fa6a99-bda0-4f34-bf5f-94ee3da59d10", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F42", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682325000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F9-3370649e-0f7b-45ca-9fb1-c4bc386d5bb2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F9", + "sheet": "Boss List" + } + }, + "text": "Y", + "author": "person:Hunter Youngblood", + "createdAt": 1673777123000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F40-6600214c-774b-4aae-a1a5-3deeac800e89", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F40", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682283000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F6-7fa48164-558d-4c8f-8c8a-ae1d2b1f7e43", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F6", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656681337000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F5-8267aa11-317c-4981-9735-d802ed4bfa43", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F5", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656681119000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F4-87b2e190-5645-4d08-8c49-15bc0611391f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F4", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656681102000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J13-da9970d3-08bf-4aa0-9265-80d8a48445f1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J13", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682617000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F56-173be791-b6ac-43e5-b0cd-558cda204f1a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F56", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682399000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J6-f211b520-16b1-4de2-9a19-8bff8c8d4c79", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J6", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166795000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J12-a4c74b30-3c3f-49f1-84d0-6087d2ce2286", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J12", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682609000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F7-7dd64b9f-9a53-42c8-8bd5-8d28153105e7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F7", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166745000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F55-c931a407-f18e-4fef-969b-53eb757a034b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F55", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682394000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F39-cd2af285-e172-425b-b782-34fb7ef4d7a1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F39", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682278000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J34-a8e7d4ab-f740-40f6-8772-919c105bc797", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J34", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682746000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J2-8c7418cc-b5e2-4f0b-aca8-0b764fe35903", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J2", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166776000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F54-a24f5c16-3c3e-4411-b06b-f8934030ce0e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F54", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682389000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-a1e9d9c5-f591-42ab-9551-5b58ae0945eb", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166396000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F38-e228f452-f4df-4946-8efd-c3fef2b07d00", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F38", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682266000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J29-e2c732f7-4d62-4f91-b04e-16cfe5d6a68f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J29", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682734000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B4-186718dd-fa3a-4f41-b8fb-3ddf4ad36f03", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B4", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166429000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F51-7d41690c-034a-4841-86af-f93de20a1a21", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F51", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682380000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F37-7c316af8-a1ac-432a-ba9e-b984a0493f1b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F37", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682256000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J28-7421fdb7-6210-475f-b99f-89884c0961c0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J28", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682729000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B5-ed6ebb16-90d0-409e-b118-3d60434b7503", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B5", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166445000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F50-030fbc5c-c63b-4c60-86d2-03be900c10ce", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F50", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682375000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F47-daf24a3e-f8f8-41ac-a6c6-0e9fc9db23e8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F47", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682366000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F35-266f8a89-3230-4944-a872-029b7805c088", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F35", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682237000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B3-6492cfb8-a1c0-45b5-96b3-76ab913b22b6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B3", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166411000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F46-f6409dca-ace0-403a-8a5d-065296388e8f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F46", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682361000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F11-974f06d9-d837-4d5f-8896-70747c8755b2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F11", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682197000, + "replies": [ + { + "id": "xt-1-F11-974f06d9-d837-4d5f-8896-70747c8755b2-r1", + "author": "person:Gianchistuta", + "text": "x", + "createdAt": 1657135571000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J38-db5c1b99-c0e5-45f6-b12e-14390b09eabb", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J38", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166993000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B13-456ad2e9-36a4-4973-b847-ce01783f35c5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B13", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662215000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B18-c1ccfd23-cefd-411f-8751-9aac0ca01c1f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B18", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674394382000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F45-a8090a4e-325e-4fe7-850e-c90cdc3ac7da", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F45", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682347000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-6524f2c5-d46a-42e8-8480-b66e832080d0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "C", + "author": "person:Sasquatch", + "createdAt": 1653438792000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B19-e52d435b-cfa6-4265-a7bf-0ecd3341903f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B19", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674394412000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J21-2b598bcf-a361-4317-a741-708a2b3bd974", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J21", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166948000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F10-52d135d2-9fe2-45fd-b9b0-9e259cce8a9e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F10", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682188000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B23-9c918f00-9543-4655-9a62-626502012809", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B23", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674394421000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F3-a8ed755e-722b-47ec-b9b3-db66a7a298bf", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F3", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656681096000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J11-8c29cc7c-4740-42c2-b069-fa1166bf94b4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J11", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682604000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J20-d6b09ba0-80c5-418c-af49-2ea7243d2096", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J20", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166952000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F9-53fbe29c-bd7d-4b86-8cea-e0e7ded47d65", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F9", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682170000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B22-bf0a6124-a1b2-4446-9e28-45d7fdf215e6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B22", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674394439000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F2-3938db5c-fe4b-4393-aaf7-d71e4cc943cd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F2", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656681036000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J10-e5ada42f-7c75-4daf-95e6-0f2839a17ad8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J10", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682584000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J9-0aadb2cb-56b2-4ec5-904a-b4cabedf7008", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J9", + "sheet": "Boss List" + } + }, + "text": "?", + "author": "person:Senna Thorns", + "createdAt": 1653166831000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J9-46a24e53-b305-4adc-8b20-bf02ed416e43", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J9", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682578000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J14-777a320a-6b00-4fac-ace3-062ad19a3b51", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J14", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166843000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N11-b1817406-2eaa-4272-8082-2b01ea781139", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N11", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653167227000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J7-5a674a52-de42-4468-b603-822b0a9346ca", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J7", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166802000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B9-220cc916-fccf-4c9d-857c-9e3ff656f292", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B9", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656662030000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J11-c22d4732-9803-4292-b390-3d748e7ad29f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J11", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Senna Thorns", + "createdAt": 1653166820000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F8-6f455991-3a54-4891-af1d-010f13aac3fb", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F8", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656681408000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B8-625da40a-b2a7-46d4-849e-ef5d0b02aa11", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B8", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656661957000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J42-c43b12cc-e2e5-47c5-8a3a-cc3c390fe1d5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J42", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682822000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N51-35bbf450-3924-469d-880b-dbba4bbae5c2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N51", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682902000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-7f4a80fa-6457-4779-86bc-d08b9c6d7831", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "x", + "author": "person:Eastnq", + "createdAt": 1656160574000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-8b1b077a-cc7d-4bf8-af96-afadff47c5b7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674394235000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J41-139153d2-720b-4ad4-b21b-a24a61a70e33", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J41", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682812000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B17-2dfa42f7-afb2-480c-bd02-cb83cf8d3b89", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B17", + "sheet": "Boss List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845997000, + "replies": [ + { + "id": "xt-1-B17-2dfa42f7-afb2-480c-bd02-cb83cf8d3b89-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166642000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J26-3e74a8e5-050b-4853-bfae-d7e9288a8c0c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J26", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682711000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B9-9d602410-b90c-4e2c-b50c-b65e57450cc1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B9", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674394312000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J40-77f222b6-6d0d-4ea2-9795-140614e305c8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J40", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682806000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J25-e5205a66-c377-41b7-9ebe-8b1ff9bf978a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J25", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682704000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B8-048aef24-f00f-4f5c-87f2-c213bfd5af08", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B8", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674394327000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J39-bc690cc3-8b7d-4c6e-8db8-6ce7a9729e4a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J39", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682793000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J24-bdee1879-b636-496a-894e-9442ef7578fd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J24", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682699000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J38-391a4c2e-2f0f-49ca-b888-82896735b9a6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J38", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682788000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J22-6568dafc-7682-4d80-b854-80feb043cd5f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J22", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682685000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J36-8a5c4b2a-4886-4f97-a892-d7b1e3f79241", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J36", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682774000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J21-892dcf03-8b54-4a5f-abcc-749984d3fc0d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J21", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682674000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B17-279c8759-46e5-4d1f-a8c8-4445c2f112b2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B17", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674394370000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-C2-491ddc9c-833e-4ad5-bc74-ca15d81626c6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C2", + "sheet": "Boss List" + } + }, + "text": "done", + "author": "person:Eastnq", + "createdAt": 1656160618000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J35-a7fa01e9-89d0-41a4-9279-6cd705bc22e7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J35", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682768000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J20-6d7f6499-4df1-45f3-8d49-28269a35b8a8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J20", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682668000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J33-e1a22dd1-115c-4b49-9bae-d19d78915584", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J33", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682750000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J19-7b012f4d-240f-454b-9994-32827840452a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J19", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682658000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J8-9009f1d7-a049-48eb-be87-8cf2e0bf2b3b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J8", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682568000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N50-748e548f-0643-49c8-a756-2b69a70393f8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N50", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682898000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J18-a5f0d670-67c0-4e62-acc7-07cb302ea0ac", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J18", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682650000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J5-cd86e768-ee9c-4da7-bccf-c4005861a442", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J5", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682549000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N54-9d23a393-c0c3-47e7-a030-916878bd8dc5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N54", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682889000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J17-744d8631-9abb-475d-96a5-077765c5a5f1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J17", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682646000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J4-3515f3c0-447d-4e94-a2b2-2fc57750d1cc", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J4", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682543000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J59-b8de5d50-71c7-45d1-bef6-6436544efcc0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J59", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682878000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J15-bc89a8e7-4e94-47da-9649-2d231c4b10bd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J15", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682632000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J3-2d6d4536-db07-4aa3-9b8e-f3e401b556a5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J3", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682531000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J14-dd3aa71d-26a2-4bdc-af9c-dca5bf05ede9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J14", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682625000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F59-25dd1551-b10b-415a-a45d-24c483477399", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F59", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682407000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-d6ec8bbb-4ed4-4df9-accf-018c689eb201", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "Done", + "author": "person:Laura Manning", + "createdAt": 1661749421000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-eb696268-997f-4b10-aab1-d5103e379b3c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "1", + "author": "person:Jordin Ward", + "createdAt": 1732690995000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-1ce8e62f-e3f0-49b9-a4bd-774b0ba05ff6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "True", + "author": "person:Jack", + "createdAt": 1717972395000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-I49-65ebd600-4dfe-4010-a606-7e3515ce7902", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "I49", + "sheet": "Boss List" + } + }, + "text": "What do these negative numbers mean? For siofra river, ainsel river, nokron, etc.", + "author": "person:Malaclypse Younger", + "createdAt": 1724744022000, + "replies": [ + { + "id": "xt-1-I49-65ebd600-4dfe-4010-a606-7e3515ce7902-r1", + "author": "person:Miles Bishop", + "text": "maybe cause they're underground? that's my only guess other then an error\n1 total reaction\nMalaclypse Younger reacted with 👍 at 2024-09-09 09:38 AM", + "createdAt": 1725351683000 + } + ], + "resolved": false, + "history": [] + }, + { + "id": "xt-1-F36-2797a5b3-fa67-42cd-b77f-632cc5815b49", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F36", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656717880000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F33-d9758019-9dd0-406f-83bb-945ebfea9080", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F33", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656717710000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J57-0cc243f2-22fd-4921-a7da-e9dcf5bd7570", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J57", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682867000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J54-50c3d530-ef04-413f-b477-121ce0481e10", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J54", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682859000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B16-9de62e43-11d2-4fc4-a7f6-6dcd8f7beb28", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B16", + "sheet": "Boss List" + } + }, + "text": "Done", + "author": "person:Laura Manning", + "createdAt": 1661749460000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J51-01fcba7a-9f43-4166-beb0-a4dded431f39", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J51", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682850000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J47-c73aa1fd-2ebe-4546-ac7d-b9a63cff92ab", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J47", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682835000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-db7ce21c-733d-43c4-8c30-847ad18d0641", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "G", + "author": "person:Alex Guzzo", + "createdAt": 1652330406000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J46-a21ac805-47b1-4eee-8f73-32e578f0d7df", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J46", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656682831000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J43-daaa3921-5c4f-450d-a7c9-6baa6756b149", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J43", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719429000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B16-a4f4fbfa-5ec6-428b-9a9e-635dd5cb2889", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B16", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652763203000, + "replies": [ + { + "id": "xt-1-B16-a4f4fbfa-5ec6-428b-9a9e-635dd5cb2889-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166638000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B14-11c14671-ad55-407b-ae7a-0ea9f9ecb1b5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B14", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652762003000, + "replies": [ + { + "id": "xt-1-B14-11c14671-ad55-407b-ae7a-0ea9f9ecb1b5-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166630000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J37-6565cbf1-9d9b-4142-afe5-869bb31e5d99", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J37", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719367000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B13-de32e7f4-0a38-43fe-8e9d-e5c33198c4e5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B13", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674399028000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B3-6709d172-3dd0-4a25-bdcc-62a044ec7958", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B3", + "sheet": "Boss List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652763898000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B6-31c6b178-be29-495b-9a3c-e54c2a205a18", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B6", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674398328000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-A1-370cc073-c146-4e28-9dbe-fe31b633a6dc", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "A1", + "sheet": "Boss List" + } + }, + "text": "will you make one of these for the new dlc when it comes out?", + "author": "person:Harlen", + "createdAt": 1709073966000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J27-b8f91fd0-7353-4e60-abf7-ce6b748c56df", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J27", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719312000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N59-680b61d2-cc12-486a-82cd-8fae2bdf39f9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N59", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656717551000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B7-f39b04fa-f1fb-445c-826d-18d0c269df0d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B7", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764287000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J23-2bfa59e9-621a-4ca1-bf7f-6b85d345604d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J23", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719252000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N58-cb0f4ec2-41d1-4c9c-ac19-4a5b77353dff", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N58", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656717547000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-74331404-b2bd-4eb6-b3b1-99a7ad935ec0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764276000, + "replies": [ + { + "id": "xt-1-B2-74331404-b2bd-4eb6-b3b1-99a7ad935ec0-r1", + "author": "person:Daniel Ceballos", + "text": "Done", + "createdAt": 1652793855000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J16-262eb4d7-8aae-4216-bcff-c90c074b402d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J16", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719180000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J7-2d1abd8a-931d-4454-9c2a-fbf3e310d2b0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J7", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719138000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J9-e456286b-c896-46d0-a982-7c9f46b350bf", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J9", + "sheet": "Boss List" + } + }, + "text": "D", + "author": "person:Corey Mcl", + "createdAt": 1673939768000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J6-07aa21d5-2ab5-4ea0-8045-949b63695f8b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J6", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719017000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B10-f8db591c-a73b-469a-8109-93b0b356cf06", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B10", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674398001000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J2-9517b4bd-4619-450d-997d-87189258791a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J2", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656718913000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B8-b649c1f3-6b81-43ec-b79c-39cf7d385530", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B8", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652762055000, + "replies": [ + { + "id": "xt-1-B8-b649c1f3-6b81-43ec-b79c-39cf7d385530-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166588000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-F41-2dbc8250-4530-4cc8-b8a3-91bd8da86f9d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F41", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656718798000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B9-f7198cc3-88ad-4160-96d3-745acd5a1f91", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B9", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652762050000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B5-45198be0-bab5-4ef3-8b6c-cc000463cae4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B5", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652762077000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N3-e5e73c3f-04d2-406d-bad6-b0f711d35c0c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N3", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719607000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B6-c81829f3-a8bc-4521-9def-a5843067e799", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B6", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652762065000, + "replies": [ + { + "id": "xt-1-B6-c81829f3-a8bc-4521-9def-a5843067e799-r1", + "author": "person:Senna Thorns", + "text": "!", + "createdAt": 1653166555000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N7-0d805276-ae85-4c86-abea-cf90c2de761c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N7", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719575000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B23-cec34288-2c0f-4ec7-8a18-72b751cbdd9f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B23", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652763185000, + "replies": [ + { + "id": "xt-1-B23-cec34288-2c0f-4ec7-8a18-72b751cbdd9f-r1", + "author": "person:Senna Thorns", + "text": "!", + "createdAt": 1653166590000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B20-ef595f17-90bc-4dba-b29b-74a08c7868ee", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B20", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674397804000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B15-b38115d4-72d0-4bdb-a162-0ebcd3a6750b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B15", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652761998000, + "replies": [ + { + "id": "xt-1-B15-b38115d4-72d0-4bdb-a162-0ebcd3a6750b-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166634000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N5-7197eb7a-d5dd-4c55-922a-32e373c194b1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N5", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719554000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B4-91f50f8e-384e-49e0-80db-14b3ceff8d06", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B4", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652762882000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B5-7fb43083-b003-48e6-834d-85112a07c7d5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B5", + "sheet": "Boss List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674394853000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B18-75bf655b-53b3-4f42-9904-67c8acd703b3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B18", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652761991000, + "replies": [ + { + "id": "xt-1-B18-75bf655b-53b3-4f42-9904-67c8acd703b3-r1", + "author": "person:Senna Thorns", + "text": "X", + "createdAt": 1653166650000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-B2-230a00c6-257a-42e0-b8ad-c59b352794c6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B2", + "sheet": "Boss List" + } + }, + "text": "x", + "author": "person:karuf5", + "createdAt": 1718504953000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N4-9f6e5de1-d30c-4e7b-b192-4cc0ff4aaa3a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N4", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719544000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-N2-081386d5-e2c2-42af-857a-18db18ae2ada", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "N2", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719529000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J58-e09c106c-7d31-4b62-a291-8c3c870d15da", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J58", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719480000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-1-J50-ec18c903-8c46-4389-985a-0819093c852b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "J50", + "sheet": "Boss List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656719468000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-2-B3-bd41a67e-3b84-406e-a454-c92df0e00d0e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "B3", + "sheet": "Invader List" + } + }, + "text": "✅", + "author": "person:Brandon Beaver", + "createdAt": 1674399321000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C61-ee8a5c1d-1b37-4b1a-91ae-4142887465ef", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C61", + "sheet": "Weapons List" + } + }, + "text": "x", + "author": "person:Cöque", + "createdAt": 1652175063000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C76-4469afea-de6c-4c99-88b7-9489fa16960b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C76", + "sheet": "Weapons List" + } + }, + "text": "x", + "author": "person:Cöque", + "createdAt": 1652175085000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C150-86e87306-a290-4e48-87e9-2ec81211d032", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C150", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656733103000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G116-4ac90810-ac85-423e-b221-60490d1fd128", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G116", + "sheet": "Weapons List" + } + }, + "text": "B", + "author": "person:Marco Teixeira", + "createdAt": 1652840639000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G129-13003429-8351-4e15-bae7-ed0fc8480e51", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G129", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840767000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G106-48be9e06-8cdf-4d14-8590-7db8a6ac2488", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G106", + "sheet": "Weapons List" + } + }, + "text": "B", + "author": "person:Marco Teixeira", + "createdAt": 1652840627000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G132-735a6dff-196a-45ba-ac0f-05fa01caec59", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G132", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840751000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G111-2ab72307-1143-40a1-8bb8-bed062720aa3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G111", + "sheet": "Weapons List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652840657000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G140-35e94ed3-9272-4164-8288-acdd19626d1c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G140", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840787000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G108-eb5a702e-63bd-4754-838d-7794b15bc687", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G108", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840595000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G97-06ea12f2-8537-47f4-81c2-21ee71868bc1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G97", + "sheet": "Weapons List" + } + }, + "text": "V", + "author": "person:Marco Teixeira", + "createdAt": 1652840704000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G105-984bad79-3b55-4220-a546-0ef6710a07c1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G105", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840693000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G93-bb36d825-129f-4ee4-a3f4-c66fb33bc426", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G93", + "sheet": "Weapons List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652840619000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G124-182c21ea-2cd4-4325-8ef8-ba791c6bc5ac", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G124", + "sheet": "Weapons List" + } + }, + "text": "V", + "author": "person:Marco Teixeira", + "createdAt": 1652840738000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C108-49b73f7a-4a58-4de4-a424-2a5f318f5215", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C108", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656732863000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C106-f18cfa29-794e-4113-810e-2a80b31b73d4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C106", + "sheet": "Weapons List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652846510000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G92-b372e8bc-276d-4fc8-a5b5-8480a07d55c9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G92", + "sheet": "Weapons List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652840615000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G143-f7787b08-317d-4ce8-9729-14efcc2bfc3e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G143", + "sheet": "Weapons List" + } + }, + "text": "V", + "author": "person:Marco Teixeira", + "createdAt": 1652840721000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C100-cf8cab0e-55af-41fb-8bee-284b6a3d92b6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C100", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656732812000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G122-761d8708-7d3b-46f3-bed8-c142a930d546", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G122", + "sheet": "Weapons List" + } + }, + "text": "B", + "author": "person:Marco Teixeira", + "createdAt": 1652840896000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C92-5fc69cd8-065c-408f-bde6-38eb129751d9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C92", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656732750000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G125-cf907905-d1df-45bc-a575-6e605bcb22b6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G125", + "sheet": "Weapons List" + } + }, + "text": "V", + "author": "person:Marco Teixeira", + "createdAt": 1652840865000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G56-c300f5b2-1031-4d09-89ae-d1303242a41d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G56", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Rushawn Head", + "createdAt": 1671427657000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C68-87d97589-1fdb-4e42-b29e-37350776c237", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C68", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656732616000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C27-d841d6ad-ad3e-4357-8176-226f865250e1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C27", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656732462000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G127-ae0bd4f4-a979-4001-b112-29b5eaa0635d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G127", + "sheet": "Weapons List" + } + }, + "text": "J", + "author": "person:Marco Teixeira", + "createdAt": 1652840915000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C18-396ceb58-d022-432c-985f-6e449a29cb42", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C18", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656732383000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G126-1e148c94-5127-4e7c-a7b5-83af9ef6d1fb", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G126", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840797000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C40-e09b981a-faed-4f56-938c-3f01dbd35d1d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C40", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Paul Miller", + "createdAt": 1712259195000, + "replies": [ + { + "id": "xt-3-C40-e09b981a-faed-4f56-938c-3f01dbd35d1d-r1", + "author": "person:Paul Miller", + "text": "Marked as resolved", + "createdAt": 1712259300000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G123-cc8652a7-a9ef-47f3-a947-9327693cd6a8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G123", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840841000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C11-7bd0c690-13b8-44af-bf63-f3881d7dfb87", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C11", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Sean Lichanec", + "createdAt": 1656732293000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G133-66878b1f-876c-4474-a34d-1cd3c8878ecd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G133", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840821000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C236-2c8b3c8f-68ec-4260-85c7-4f67d048f94d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C236", + "sheet": "Weapons List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886266000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G61-1553907a-b623-40b2-96e6-f4f167d5649a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G61", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840345000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C268-b22c6378-9316-4795-b5f3-1576d97ce2e0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C268", + "sheet": "Weapons List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886284000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-3-G64-d5e40a56-c4aa-49cd-99a4-e91c8529b97b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G64", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652840354000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G62-cdb97a1c-1057-4785-bc7a-c4bc6eb07919", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G62", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840349000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G77-065d0455-8fa6-475c-b7fc-02809d93483f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G77", + "sheet": "Weapons List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652840484000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G88-9641729b-5a95-43b2-884d-148315c25ef3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G88", + "sheet": "Weapons List" + } + }, + "text": "B", + "author": "person:Marco Teixeira", + "createdAt": 1652840509000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G82-cf1cad14-bf03-45a2-b221-5f91e29dcfbc", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G82", + "sheet": "Weapons List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652840494000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G147-99abeb69-1381-4990-8e14-f577793e4fdc", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G147", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840408000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G149-9e7a766a-bad4-4b3f-863b-cf8c18fa33ed", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G149", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840419000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G148-30f23d00-ff50-416f-9689-3602820eff20", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G148", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840412000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G67-c8adb998-efde-4075-a844-d3c1269fcf4e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G67", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840368000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G65-0d846dea-3b58-4fd1-a25e-0b82747e6e43", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G65", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840363000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G152-46cd658d-bfbd-4658-ab52-e18f7877a238", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G152", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840403000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G151-3bc8a2cb-450a-4a44-8ef3-d4ac07e12bad", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G151", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840400000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C26-2a934558-eec3-4dc8-9944-3788166e798c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C26", + "sheet": "Weapons List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886214000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C23-e8ab919e-9944-469a-98cc-b5ea37bcba0a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C23", + "sheet": "Weapons List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886233000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G116-450dd553-eea6-48bd-9d2d-0302c368037c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G116", + "sheet": "Weapons List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886353000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C248-1ff67f4e-387b-4820-a643-c9835459e65d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C248", + "sheet": "Weapons List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886301000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-3-G147-6e0206e4-89c4-4238-a351-e33f40a16c73", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G147", + "sheet": "Weapons List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886251000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-3-C169-4327acd6-1ef3-4cab-b27c-45a45e611f3f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C169", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839517000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C189-6d528a7b-294b-4a3f-b0c3-6dab06c93aa1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C189", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839629000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C167-b32cbdf9-ee4d-42bd-bac0-aa2bacc2f17d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C167", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839511000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C186-9a915292-cb11-4d14-b143-4524127db504", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C186", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839625000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C171-5254582d-6cc4-4ef7-ab55-fc49526a6332", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C171", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839535000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C197-4fe48a2e-24f9-4897-a4fd-0d2377ffcd11", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C197", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839654000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C257-e1b19004-30a4-41dd-a431-d1145058ce75", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C257", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839895000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C170-a16971ad-608f-4c30-81d4-cfca8ffc5f58", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C170", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839523000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C190-dd772330-3c09-407b-a0a9-7da96816e571", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C190", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839634000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C251-53b40569-4a2e-4098-8506-1ae59805e796", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C251", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839880000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C217-2736359b-4686-455a-9a79-e9dc81eeafac", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C217", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839741000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C144-0505a5e0-633c-4a34-8e2a-25844b34beb0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C144", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839410000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C165-0db67a18-23f4-496f-a5e1-7a9479704e71", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C165", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839497000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C214-cac5e533-e0b0-47a2-af46-e100f36822a7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C214", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652839732000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C143-9ff394cd-cc79-48bd-9fad-335033634ef5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C143", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839402000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C164-1b2c4234-a1e9-4927-b193-19766ac527cd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C164", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839493000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C181-4431a0c0-fecb-4165-9eee-968103fea0b3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C181", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839602000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C229-c2ff6a59-47b9-4d39-9eb9-d05f89869fb0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C229", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839777000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C203-8cc4d5f6-0353-4b73-9bda-f6fc398dab52", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C203", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839687000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C224-b78749fe-0b33-4dc4-8b63-7e7498dbb4ea", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C224", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839756000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C202-7d53879d-a50c-4a5d-b491-b654168366ca", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C202", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839684000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C233-c0d7a3ec-38bf-4a66-8945-767a5fa1e359", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C233", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839788000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C12-b3f3723b-5e4f-44ea-9409-eb86429d0c1f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C12", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838550000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C288-dbb968ef-ecbb-484e-98b2-980701b59ef6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C288", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840048000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C206-ddfeb9fd-f282-48b8-a899-83d6a8adaeb7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C206", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839705000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C230-ccc78bca-b60f-4888-a564-5c12c58f2f22", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C230", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652839780000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C9-41a4f0d0-c0cc-451e-8043-07831d07a3ef", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C9", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838538000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C289-2d503282-4e07-47c6-98c0-1b7148995191", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C289", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840045000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C205-5ead8b5c-240f-4342-86d5-b0fb71fe0c2a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C205", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839701000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C173-02ff82ce-9c3c-4091-98ce-f357520662e0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C173", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839542000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C199-8e1aaf69-bc03-48c2-ad37-d1c1b69c6d5d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C199", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839666000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C172-ba9c4b17-a803-446e-a990-4e726c918a61", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C172", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839539000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C198-ddb2ab98-dc85-42a8-a446-89d9bfb8346d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C198", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839662000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C260-bf0d0653-fc5b-4b1f-b7d1-6c12c0a79b3e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C260", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839935000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C220-abbb7052-da2c-451f-b9e0-a7f269c6401c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C220", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839746000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C200-4543e838-5d35-445e-8955-508be3a3af0d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C200", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839679000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C101-eb6d0c4e-f11f-4b97-9c81-de1f7cb4683b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C101", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839162000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C118-53ed5d84-edab-4abe-9a8b-d021ab393eb3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C118", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839267000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C99-950abfae-5d90-4ac8-90b2-f2a49d5d38d2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C99", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839148000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C117-96d6a53a-9dc3-4af8-bbcc-644e6fc3e0f3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C117", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839261000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C179-a30a29d9-81ca-44e5-8fbe-6e926d5f3627", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C179", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839573000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C103-6505efbf-af29-41a8-b4b5-d54a38945592", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C103", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839169000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C127-95eaba3b-76ce-4020-8e5f-864d6e410e0b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C127", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839288000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C102-24db3f96-8bad-4d00-a59e-3c6440725832", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C102", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839165000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C129-ff6c078e-300d-4cf2-a7a9-4fc23eede6e6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C129", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839282000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C95-cac2e409-6769-4164-90de-fef5a9a7f5bd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C95", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839132000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C151-8b28d49f-f4e1-4618-a6e1-920e5ce46b96", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C151", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839435000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C79-b373a1e7-6d2a-40c0-a238-52eb66d7fb0c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C79", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839042000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C91-bf605edf-ebc7-4c15-a404-2e46a45ee4c1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C91", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839114000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C148-fddaeeab-6180-4406-a0a8-ab7029c61ffb", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C148", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839429000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C76-71fa1bf5-bebf-422c-8415-d499ab8e0a89", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C76", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838975000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-A225-1014c204-1cbb-4efa-98f5-0aaa7129d008", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "A225", + "sheet": "Weapons List" + } + }, + "text": "Can be permanently lost if not obtained before beating maliketh", + "author": "person:matthew harding", + "createdAt": 1650831529000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C122-dc555e6c-6047-4a2b-a5f2-bee4da78bec1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C122", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839252000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C96-560beba6-9b5e-4037-bb93-2a22df14967b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C96", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839142000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C137-1e623e78-a5c5-4f86-a4b1-5bf065252ed9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C137", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839375000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C154-b136a674-14ec-48c9-83d4-edd241f429f3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C154", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839468000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C134-26b7c579-0d7c-4a37-9a12-81d8f3e4bcf0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C134", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839369000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C152-f5942254-9590-4ae0-b646-c54b58056d26", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C152", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839462000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C212-b1caa58c-da40-4f58-b3ce-88abd86c3f44", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C212", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839728000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C140-43f2da54-caa2-47e9-9c04-eed83595cace", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C140", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839396000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C160-253eaab2-ef27-4d3c-958b-5a1dc4faaf3b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C160", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839487000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C138-13849dcd-2c85-4b96-ba1c-c11f13ff5502", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C138", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839389000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C155-a2bdb974-e15e-45f0-87ed-716160f4918a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C155", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839472000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C185-9ee35ddc-e593-4f4b-a5b8-22befa7b58fe", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C185", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839597000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C110-44d36ec8-dbd1-4499-92e9-b3637d6bb18d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C110", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839219000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C133-8371c9d6-35b6-47be-9032-ff9fb2c25b8a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C133", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839359000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C180-9651a939-5fe5-467b-a35e-1049c8cde0b9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C180", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839588000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C107-40842753-3a4a-48ac-b117-f23ba2e59ced", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C107", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839211000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C126-de994af3-7c6a-411f-9f6f-1cc0b3aa202d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C126", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839292000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C153-66211899-b7ea-4f68-a896-79dc03774bbd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C153", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839457000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C50-cc865805-a96f-4a69-9d0e-5adbfe4289df", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C50", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838802000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C62-11cf3a2b-35b2-4820-b503-6a3dea7ec6a4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C62", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838903000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C61-c3f792a5-3955-44f6-88b8-377d701f9497", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C61", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838893000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C52-fca31cda-c81b-4bc3-b58d-6a66fd013ca1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C52", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838822000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C66-d5c963fb-bc6e-476a-b0e2-58bd8e63e402", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C66", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838923000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C111-1cc2acdf-d5b7-49e9-bbd7-4b2c3340a2fa", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C111", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839223000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C51-9bdd1b50-cc20-4b41-bed2-09b040c009ac", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C51", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838818000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C63-9766710a-eeff-4370-b96b-cf48928b4c3b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C63", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838907000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C81-c57cab86-20d8-4ac0-8323-70d64adb43e6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C81", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839049000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C48-cc483d02-230f-4dab-951e-321f59560df2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C48", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838795000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C80-9608d989-da6f-4faa-8022-02992e8d0512", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C80", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839045000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C47-d006e32c-7432-42e9-a152-c7337d039659", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C47", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838779000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C84-e70ab4be-1dc0-4cdd-9cec-58a791e7045a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C84", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839065000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C49-e8759280-00c6-4599-a3d5-e663c88bf85f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C49", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838799000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C86-670d58f2-3616-40f3-90ef-376ac66e3393", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C86", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839073000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C73-8b012a51-977a-47ee-a379-ce165a8b8c00", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C73", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838965000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C85-175a6483-120b-4662-858b-ffcd2fa4b698", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C85", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839070000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C72-6d65fdb5-4cb9-48c2-8470-c23387849eea", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C72", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838961000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C89-4d0e5697-229e-4952-9e25-ebe05528c593", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C89", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839107000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C147-2e7b5ea0-8f95-4354-8107-6ae7aee0b1a1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C147", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839426000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C75-a0dc9ea5-fb56-479a-a12b-43010593d749", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C75", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838972000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C93-c813cd18-4938-42e0-b1ae-268fab58d831", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C93", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839101000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C74-908718cf-ddf7-4b9b-9d44-0f5893cfc414", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C74", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838968000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C54-10fac7f8-ed16-41bf-9523-342caa8b35e4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C54", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838854000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C69-b2a40211-8016-4ba0-92d8-7c1fc48dc7bd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C69", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838934000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C114-a2e3d554-8ed7-45c0-92ac-fa1c356fe144", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C114", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839237000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C53-8a3d2dd2-bc27-4bfd-9a86-2678c587df8d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C53", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838826000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C67-914e0afa-5f44-49cb-8811-3f2ffed06e22", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C67", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838926000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C113-904c2a88-6cc7-4b27-8371-525ee971d205", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C113", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839234000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C55-45acab27-4b05-4a79-91f1-a66666e280d3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C55", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838857000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C70-7e266373-676f-4e3a-9b9f-e6620586a9de", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C70", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838937000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G80-6a785959-132b-4064-8b3a-72da36bf18bc", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G80", + "sheet": "Weapons List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652840462000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G75-11fbef30-7cd4-422e-9836-65546d0bf213", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G75", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840434000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G78-7af0238d-8b77-4ccf-b353-39eb18dd3a37", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G78", + "sheet": "Weapons List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652840477000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C39-c2c868a0-fb23-46c7-9cda-d2da8efc569b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C39", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838746000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C37-5fa92592-8697-4fcb-adaf-40eb6dec6b25", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C37", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838740000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C45-ac280d7c-6d90-445e-afa7-01c435a483e8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C45", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838774000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C44-c070ee7d-561a-4ae7-a02e-24f3ba267590", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C44", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838769000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C59-7f65570e-d50a-48dc-83b3-897408c158d2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C59", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838886000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C57-1742e75a-6285-4c15-b03f-08535857bd1d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C57", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652838868000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C60-1fb5d324-1b77-404f-a2c0-89b44922a485", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C60", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838889000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G91-598deea6-a3bc-48fc-bca2-1969304012e6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G91", + "sheet": "Weapons List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652840680000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G115-d1e93a2c-7180-47a5-99dc-a320e67abd28", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G115", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840663000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G102-a8ba49a6-80cf-4865-8693-f3c21558c1b7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G102", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840686000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G79-62975d7c-3d9e-4477-bac1-308ed0af566f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G79", + "sheet": "Weapons List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652841222000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G109-cf861612-83ff-4789-a47b-5564211a4927", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G109", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840584000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G101-742068e2-fc43-47fb-b763-15a0ef0adce4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G101", + "sheet": "Weapons List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652841207000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G110-e7cde5fe-f231-4f47-9004-0cd5c276c221", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G110", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840571000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C132-8eef6fae-c504-4362-b266-48535a4f9d52", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C132", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652841243000, + "replies": [ + { + "id": "xt-3-C132-8eef6fae-c504-4362-b266-48535a4f9d52-r1", + "author": "person:Caleb Fairfield", + "text": "Marked as resolved", + "createdAt": 1653274549000 + }, + { + "id": "xt-3-C132-8eef6fae-c504-4362-b266-48535a4f9d52-r2", + "author": "person:Caleb Fairfield", + "text": "Re-opened", + "createdAt": 1653274550000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G107-3573b309-ad0e-4d73-99db-2f1cce92a167", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G107", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840591000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G83-1e39765c-9acb-4293-b61d-4617e377f221", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G83", + "sheet": "Weapons List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652840526000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G85-656fb479-7340-4407-ab9b-6a33d5a55f6d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G85", + "sheet": "Weapons List" + } + }, + "text": "B", + "author": "person:Marco Teixeira", + "createdAt": 1652840516000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G84-252c74f3-f7de-49e9-9df3-e0d805937ea4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G84", + "sheet": "Weapons List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652840554000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G74-48fbe31b-1ea0-4044-8477-ab3baf7b85ae", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G74", + "sheet": "Weapons List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652840541000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C62-1a7d41a0-ad45-4318-9ede-cd87fd4cfa93", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C62", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Daniel Broadbent", + "createdAt": 1671620944000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C4-9828f409-c65e-40cc-98f7-20423e96a9d0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C4", + "sheet": "Weapons List" + } + }, + "text": "✓", + "author": "person:gabriel fernandes", + "createdAt": 1658184962000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G16-5c5a2774-32e6-4834-a784-5f427d0ebf9a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G16", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840137000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G41-13003de7-d13d-46f4-b575-ee9b552db479", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G41", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840230000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C29-7d14f123-63cc-4374-90d7-d5e787a13f47", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C29", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838687000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G17-91d92010-dce4-4701-927b-4f08b823225a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G17", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840133000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G40-e46e3804-991a-44d0-a3b9-46553cce503f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G40", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840219000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C28-b0d5667e-01b1-464d-aaa5-ae501715edbc", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C28", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838674000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G23-ca75f012-c8b2-451c-b87c-bc1256aed5f0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G23", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840158000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G43-5e3be9a8-4f69-44c3-ace6-bd254e28966a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G43", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840239000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C32-409ad6e9-84c4-4015-a826-12109c6ae3fd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C32", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838699000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G21-34f6c199-d0ec-4fe6-9dc2-e6c79e3242b0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G21", + "sheet": "Weapons List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652840154000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G42-9142fdc2-86a4-4239-92b1-280127301b0b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G42", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840234000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C31-634fa129-b086-4eec-83d0-5debf73407dc", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C31", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838694000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G60-14de8732-6c0c-4e01-905e-e47abcb368e9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G60", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652840341000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C25-c55721eb-7f4d-4970-90ec-17ec1c2a5933", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C25", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838626000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C283-e8d5efd1-4720-4fb5-a680-b1fb16a75d43", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C283", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840030000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G11-a5561b08-f3ac-4623-b4db-17f606cd8e39", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G11", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840124000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G14-d039b694-37f4-40ce-87a3-071e7663a135", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G14", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840130000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G35-9aa14e9c-8a3a-4fde-bc19-312d659927a5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G35", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840201000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C27-0d093ed1-5afb-4436-9085-61d33affa4c3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C27", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838670000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G36-d85a2421-fe8d-449a-88f3-88cfa3feeeca", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G36", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840197000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C26-032e2054-f9e0-437e-b360-6546d2a61e49", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C26", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838666000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G50-e2f75395-68d3-4a70-9195-e6ac05cccbcf", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G50", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840294000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G47-5bbd2d78-c08d-417d-86cb-00579d525df0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G47", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840277000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G54-e433378c-c435-4cce-aba0-a8b06e62d974", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G54", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840304000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G51-5d34599d-1a98-4ad8-8789-1555734ca9d8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G51", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652840298000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G32-700cea5f-ceee-4d34-b494-f4f816904912", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G32", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840171000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G45-cec41b8c-cbfe-4335-af2d-97ee2922cfdf", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G45", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652840253000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G46-13957544-928e-483a-9e97-a9cc1e3be385", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G46", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840272000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G44-d6efdd2d-aa0c-431e-8ae7-5dddd7926544", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G44", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840264000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C245-9736193c-ac39-41a5-8fc7-83455e7d678d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C245", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839859000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C264-9f1e2abf-82ff-4c95-9219-98cb224b532c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C264", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839949000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C33-67b2c5cb-6920-4559-adb6-7942380dc51c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C33", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838717000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C243-702bef72-2678-4169-bc8f-fc8c515e748e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C243", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839837000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C262-043812fe-46f7-4feb-9e8f-a9d512af9622", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C262", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839945000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G26-a32aec1e-806c-4356-89be-1ec758aa5da7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G26", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840187000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C248-c90005ec-e84f-4820-906b-c4f1bb3cab44", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C248", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652839866000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C268-558927cf-a143-4dcf-829f-a3d107cbd8b5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C268", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839981000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C35-6278f177-746b-4a73-8a66-d0c0d709dffd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C35", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838727000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G31-f40c3b71-33c3-4158-9a8d-8d5f16e73472", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G31", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840176000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C246-95432e78-215b-48a0-97eb-2a533dff9502", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C246", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652839862000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C265-e2747d94-6deb-477b-a43f-6d49a9a03aa3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C265", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839953000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C34-3f83fb96-3b3d-498b-99a7-87b60427d26f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C34", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838723000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C237-f85e971f-8189-4139-ab57-d1cd83a0f119", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C237", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652839822000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C54-a59da0eb-0fb4-4933-b0b8-f0b34cec8d0c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C54", + "sheet": "Weapons List" + } + }, + "text": "True", + "author": "person:Paul Miller", + "createdAt": 1712256413000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C240-f6e69caa-05f3-4139-87fe-0dcb1469ea8f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C240", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652839814000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C13-ea1f758c-5c5d-4e76-ad13-8d81bc7b37c8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C13", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838558000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C286-d41bea73-b657-4ae3-8348-f8b9e5a318f9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C286", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840052000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C210-5b924aa7-cc44-4c3a-80ec-1149b74fabca", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C210", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839722000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C4-3220cb72-e452-4481-a47f-76fca97d0db4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C4", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Anonymous", + "createdAt": 1652592418000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C244-39edc295-0f85-4f79-ad38-c1ecb85637de", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C244", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839833000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C263-a2fc33b5-a95f-49bc-adf8-ab80f0b5c615", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C263", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839942000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C261-5ebc367e-c365-4edb-a6a9-6d8742a84626", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C261", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839939000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C19-16c27ab2-a556-47e9-b664-fa5a8d0a08f6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C19", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838596000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C280-ea1c0555-be5f-49fd-8ce9-a92d1e89bcf5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C280", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840011000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C295-4a6aecdd-b721-47c7-acd1-22ffad4536f2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C295", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840080000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C6-7e3eaf9d-a34d-4cca-9559-03770aba190d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C6", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838510000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C17-421fd29d-bc5e-4859-bf41-c7dc2b3716f7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C17", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838589000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C272-33c0276d-487c-44b4-ba3f-64afcc913d7f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C272", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839990000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C294-5632c7b4-5cd8-4844-a70f-ba0ba0760aa1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C294", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840076000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C5-d476a2bf-dbdf-4208-b96f-ae9633ca8915", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C5", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838502000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G59-b7f4e2ad-eb6d-411e-aef1-cfb7a9c43566", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G59", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840338000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C24-1792c74d-a733-4fd0-8d74-1c6a54dcbf8e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C24", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838620000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C282-091adb08-49c6-4959-bee9-673d61d5d138", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C282", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840026000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G4-388db6da-e338-4efc-9105-116f5106f918", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G4", + "sheet": "Weapons List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652840109000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G56-cd1c1b2f-088e-48b0-9909-05038d15f25d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G56", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840314000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C23-0c8da38d-baaf-4dba-9f46-42ce05e9cb4b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C23", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838613000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C281-17d0da78-df8e-4754-9b01-ac210201b8e5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C281", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840023000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G8-576cc65b-3cc0-43cd-ad45-51679debcb60", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G8", + "sheet": "Weapons List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652840105000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-G30-141fa62b-a9bf-47b7-99be-30754d1757e9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G30", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652840194000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C250-25cfda76-b70b-4d8d-84a4-535d32bc6f1b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C250", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652839877000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C269-1391b9ef-5597-457d-ba48-0eec0a29850f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C269", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652839984000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C16-c0902eb1-9f74-4731-b9c3-ed5ae24afafe", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C16", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838581000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C271-2a523826-3378-4d72-a52f-4dc906f46ae1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C271", + "sheet": "Weapons List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652839987000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C292-850a7a98-7933-4525-859e-bbdb04b15a07", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C292", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840073000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C4-b09af7f6-1b27-4337-947e-2ccc264ed3e3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C4", + "sheet": "Weapons List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652838494000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C14-fb64e644-b404-490b-884d-b723ee6e7dc0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C14", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652838568000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-3-C297-6599c5eb-f74b-4c47-9416-4e14acc78cdb", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C297", + "sheet": "Weapons List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652840063000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-5-C56-f3048e4d-bc95-49f6-afd5-19cd805fd966", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C56", + "sheet": "Ashes of War List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725885594000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-5-C4-f7ff3691-be7d-474b-8255-b263973955f9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C4", + "sheet": "Ashes of War List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725885535000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-6-C15-1789e71b-38c4-411c-98af-6ceb001316b7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C15", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764953000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C13-7bbfa1e7-26b6-4a8b-a5a6-1ae703abf908", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C13", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764945000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C18-866ee341-f202-4545-b910-5f3a3267d9e3", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C18", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764970000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C16-6f82c371-315d-45e2-ad1d-b87fad8a955b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C16", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764963000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C25-da1a90e3-a2a6-4ad8-856a-51d1b8c0954d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C25", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764996000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C20-0591e9cf-88d8-4dc5-a170-bc46b24e67a4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C20", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764984000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C28-2a35154f-199d-4ed4-9ca1-7dca61544e57", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C28", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765005000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C35-2d850eb8-5ad8-4841-b71b-770ca3a200ad", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C35", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765070000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C12-be691e4b-da2a-4ce1-9933-c9766ee7cbe1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C12", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764933000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C31-57e199ec-1b08-479e-89da-1950675a41d0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C31", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765023000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C29-39b9921b-b663-4396-92c3-7a677867de9b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C29", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765012000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C33-cd10ec6b-9bc8-4cf1-b47f-bd475b9f0b8c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C33", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765044000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C32-de5b96e6-0f4b-4a97-8430-ebd7a50d937a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C32", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765033000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C34-db473347-d1de-44bf-ae83-288edbf9c525", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C34", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765062000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C7-9db50e6d-02dc-40fe-b3cb-b30eca2f54c5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C7", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764888000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G3-0c5d3f56-19c6-4ea3-a760-c4d003125494", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G3", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765132000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G7-4f265040-e3d7-4baa-8beb-dda971667f10", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G7", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765167000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G9-bf6ea8b2-1991-41bf-807d-24b3d2f3669a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G9", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765187000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C39-462672b8-6ee3-475e-ac86-30ba1dafe103", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C39", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765078000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G4-af753ba5-c2f3-4cf4-aa8a-9347a7e5bd03", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G4", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765141000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G5-74969f1c-e106-4049-add5-788cc0ad01a9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G5", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765155000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C44-71ed9211-9dfd-4cfc-a2ee-cb8d81984611", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C44", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765105000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C45-97df85fa-86dd-49a0-b414-51196ed804d4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C45", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765114000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G20-24310fac-e1d6-4485-9d04-ed44b51d377c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G20", + "sheet": "Spirit Ashes List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652765282000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G21-e5b37080-06e1-458a-9498-284165945c90", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G21", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765288000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G17-db9ca158-fd41-4531-9ea4-d840442a4f48", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G17", + "sheet": "Spirit Ashes List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652765253000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C11-b87af7aa-c465-41d0-9678-a832cdf858b1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C11", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764922000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C8-a6e51c7c-e5d6-4423-befb-da6972de072c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C8", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764903000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G12-97a6865b-9d54-46de-9935-64d1c22704bd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G12", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765221000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G10-3c9621f9-d656-4b16-9a6b-d0ad97bb4e53", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G10", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765198000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C42-1e4c89f6-024d-477b-a299-1a139dd2608e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C42", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765087000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G11-aabe359f-6519-4028-b822-8f9cce1ed0d8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G11", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765208000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G15-143d4c69-6b33-40f5-8c2c-5ef7fe77c6dd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G15", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765246000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G16-791e0760-6a55-4d17-a5e8-0f930ea338a1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G16", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765250000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G13-55b19fd2-0b2f-46ce-bf69-96ae715912b0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G13", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765226000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-G14-671e31fa-39e5-41be-98ff-41c7c98dbb63", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G14", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765233000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C3-77f8bd4d-c2bf-4f4c-8dae-8f5954c952ed", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C3", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764764000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C2-70050085-81c2-4adc-9e69-7251180d8b39", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C2", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764749000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C43-a368678d-2ef1-4777-8bf1-dd44777d641c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C43", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652765096000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-6-C6-3ce9ea02-935a-424b-bf27-519f071d26db", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C6", + "sheet": "Spirit Ashes List" + } + }, + "text": "X", + "author": "person:Marco Teixeira", + "createdAt": 1652764873000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-7-E64-fc3d5f7a-e28a-4c3d-9b5b-6ad1abc2bd37", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "E64", + "sheet": "Armor List" + } + }, + "text": "> a chance for it to drop from Godrick Foot Soldiers.", + "author": "person:Delilah Collazo", + "createdAt": 1684891070000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-7-E62-eb4a707b-29cc-4401-8ae9-fb6218773140", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "E62", + "sheet": "Armor List" + } + }, + "text": "> drop from Raya Lucaria Foot Soldiers, found in Liurnia.", + "author": "person:Delilah Collazo", + "createdAt": 1684891034000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-7-E63-9c4d1292-084a-4584-aaee-784a09baa319", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "E63", + "sheet": "Armor List" + } + }, + "text": "> drop from Leyndell Foot Soldiers found in Leyndell, Royal Capital", + "author": "person:Delilah Collazo", + "createdAt": 1684891051000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-7-O57-2662e24b-ce97-4d2a-bd06-6fcf4b3af850", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "O57", + "sheet": "Armor List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886427000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-7-G102-72bef342-2053-43e3-bccb-fb5857e57aa1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "G102", + "sheet": "Armor List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886398000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-7-K71-2b45a1c8-233f-4143-a43d-efcc7e45fe45", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "K71", + "sheet": "Armor List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886409000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-7-C118-8f2f8927-0add-4d66-9061-ddd8b698ad08", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C118", + "sheet": "Armor List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886379000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-7-C97-31b6395f-dc7f-402f-8ba8-35de113bc03e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C97", + "sheet": "Armor List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725885326000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-8-D113-4c73ef19-3aa0-434d-bd89-d0b629b22075", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D113", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652845619000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D112-ad6aebf7-66ec-4580-bb17-6834576559dd", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D112", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845616000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D114-5c5a8134-6b8a-4740-82ae-0864f184c183", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D114", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845635000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D109-cdb006f6-16a4-4794-bba4-4a93dc7577ed", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D109", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845594000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D111-72f2b1fc-14ba-4096-bd69-649007d15c13", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D111", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845612000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D110-26792aca-9d4e-472b-8944-126b066ba1d6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D110", + "sheet": "Talisman List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652845597000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D36-7d3b3a39-abd6-467a-b115-1169add4910a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D36", + "sheet": "Talisman List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886693000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-8-D17-39f7e17b-3862-47a9-897d-39514eaa9caa", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D17", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652844783000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D33-b2e579c5-e584-4d4a-b181-16f4cb5a31d1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D33", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652844907000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D15-bd321590-9559-4154-a286-cdb56c5b279c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D15", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652844777000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D32-134bdf86-2385-4e38-8b61-9b26e6c53cf1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D32", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652844887000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D73-6af2ee76-ce2b-43af-96b2-8cb09643f6a5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D73", + "sheet": "Talisman List" + } + }, + "text": "J", + "author": "person:Marco Teixeira", + "createdAt": 1652845288000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D21-93a3110e-69ae-4308-9e07-c52093ab7944", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D21", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652844815000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D35-6fe62252-3cfb-4a94-a398-7aeb048be096", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D35", + "sheet": "Talisman List" + } + }, + "text": "M", + "author": "person:Marco Teixeira", + "createdAt": 1652844919000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D71-1f7e5545-51c3-4228-9cc1-8e3b093d37a5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D71", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845270000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D19-f7baa890-aeae-4f58-ba15-d838f0807119", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D19", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652844796000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D34-e9868bb0-8087-4e90-968e-b25f64b0b00e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D34", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652844912000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D13-4c0f7c89-31ec-49bf-9c09-0f93fa6a2246", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D13", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652844738000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D7-e4f54429-e196-4e3b-83b1-7392fc65e01c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D7", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652844707000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D48-80aa7c93-e4c3-4751-84e9-801ab3960694", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D48", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845092000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D14-cdf25538-5c39-4fbb-a071-d9a7701a8add", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D14", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652844758000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D31-ac9938aa-e682-478c-ad97-861743a058f7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D31", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652844880000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D30-1e1b7614-f150-4bc7-a93f-5a8fb7e8435e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D30", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652844875000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D40-680fd4e3-b78b-49cc-bde6-07f751b3c338", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D40", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652844962000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D39-969b2c47-6fc8-434a-86a6-11d3c0e22616", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D39", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652844957000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D56-383e8a88-bab1-475d-8a88-102cb26cb4ee", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D56", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845131000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D97-92142d04-fbb9-4b12-8436-8d92724f29a2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D97", + "sheet": "Talisman List" + } + }, + "text": "B", + "author": "person:Marco Teixeira", + "createdAt": 1652845494000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D45-9b00a837-a949-47e2-91bd-d042aa9a9d9d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D45", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652844986000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D58-494d610c-0be6-4cfb-b04a-045a92cc06d6", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D58", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845184000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D96-1d3fc43e-c692-41fb-8b4b-8013c7d44ae7", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D96", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845483000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D65-efebdadf-a085-4cf9-ba93-ef2f027097f0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D65", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845164000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D75-2e077cf2-bb75-4e50-8730-4a421f312eaa", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D75", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845298000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D22-5fbcbae4-15da-486d-8346-d491a397df02", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D22", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652844819000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D36-5f737398-b5eb-4d18-9d88-cee6c25c05ac", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D36", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652844934000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D37-6fc336ce-7349-4bac-97b3-d126ae14e58b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D37", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652844939000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D54-bb5bc5ef-275b-45a6-9225-2096aaa286dc", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D54", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845113000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D52-48d54b60-f75f-467c-9e4c-5a78663ad86b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D52", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845102000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D26-5a9cfb64-5dc2-4737-b634-87f437fa6af9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D26", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652844839000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D24-71fdf96e-807a-4237-be6d-0170f131ce41", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D24", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652844824000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D5-974f826f-f594-4380-bb82-77b66c992dd2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D5", + "sheet": "Talisman List" + } + }, + "text": "C", + "author": "person:Marco Teixeira", + "createdAt": 1652844647000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D3-1ca871b6-d7ec-4e98-942f-82209e2a8a04", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D3", + "sheet": "Talisman List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652844632000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D10-dfd96532-93cf-4bed-a606-a80611a2e227", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D10", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652844696000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D44-efcebccc-ddbe-450b-b053-015d638cafa4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D44", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845028000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D6-9f94514f-cfcc-4f58-8965-1798be000176", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D6", + "sheet": "Talisman List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652844681000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D47-8ee6a0b3-0b45-42b9-9814-1f8c224b99cf", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D47", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652844998000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D27-81db2793-a9b5-45d7-90cb-fe26ac9b8828", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D27", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652844844000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D2-6e602002-6953-45b5-a80f-c0efd60da661", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D2", + "sheet": "Talisman List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652844627000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-A10-fbd1127a-6d9a-46ee-8bb9-114fc417e785", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "A10", + "sheet": "Talisman List" + } + }, + "text": "Typo? This should be Cerulean Seed Talisman", + "author": "person:SunMar 2038", + "createdAt": 1651722755000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-A116-287c6155-e6b6-4f24-8f0a-e49f88a6d2b4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "A116", + "sheet": "Talisman List" + } + }, + "text": "Since these are kinda consumables and you can only get a limited number per playthrough, does it make sense perhaps to list all the Twigs you can get individually just like is done for Key Items?", + "author": "person:SunMar 2038", + "createdAt": 1651723305000, + "replies": [ + { + "id": "xt-8-A116-287c6155-e6b6-4f24-8f0a-e49f88a6d2b4-r1", + "author": "person:Jacob Bacub", + "text": "I guess that makes sense. That's also a good idea. I will create a separate section for those on this sheet.", + "createdAt": 1651829377000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D116-4150a089-887f-45e3-8cdd-72873217f2ea", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D116", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845643000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D115-0170cbfc-f6a6-495a-bcff-86242aaa3315", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D115", + "sheet": "Talisman List" + } + }, + "text": "S", + "author": "person:Marco Teixeira", + "createdAt": 1652845639000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D107-7aad7d3a-f36b-4786-adba-803a959c29d9", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D107", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652845578000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D106-0252a8ba-5881-43d0-955d-7f1be5f91eeb", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D106", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652845561000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D108-b6f133c8-18e4-4d37-983b-cb9ed6ea85b1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D108", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652845582000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D103-e834a85a-a2b1-492d-beed-e7f5291986d8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D103", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652845539000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D100-fd9cf136-c0be-4f90-bef2-f8d012abc845", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D100", + "sheet": "Talisman List" + } + }, + "text": "J", + "author": "person:Marco Teixeira", + "createdAt": 1652845514000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D105-77904234-2d24-47be-b9bc-67f55f10e74c", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D105", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652845558000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D104-e40e7a80-3d8d-47cd-833e-03f3250253f2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D104", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845545000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D62-9136514a-6af2-4ec8-9d67-3893f9bc9a1b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D62", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845212000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D82-dc2297eb-f932-467e-bd78-23cf8d073489", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D82", + "sheet": "Talisman List" + } + }, + "text": "J", + "author": "person:Marco Teixeira", + "createdAt": 1652845363000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D61-33186903-9c59-40aa-a57f-cdde5820fd29", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D61", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845195000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D80-c3ebdcc0-242d-4fec-a711-1446f3528379", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D80", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845355000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D68-57eb3dc0-3c67-4dbc-810a-ab20e1a18c3a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D68", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845234000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D85-a45b8981-1609-4a76-bd55-be386416bea8", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D85", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845393000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D66-44f2cbfe-e369-407b-9d2c-72ae39cb5283", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D66", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845222000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D83-157b1420-6aea-4597-8938-57939ea75fb2", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D83", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845375000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D99-1102b9e0-ca69-4cec-a5bf-127ce92fdcff", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D99", + "sheet": "Talisman List" + } + }, + "text": "J", + "author": "person:Marco Teixeira", + "createdAt": 1652845511000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D46-efee528b-1b2c-47fa-88b2-89bd5887a6f4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D46", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652844991000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D59-bdfd1746-cf6f-471a-8c49-4d4cd33be1f1", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D59", + "sheet": "Talisman List" + } + }, + "text": "F", + "author": "person:Marco Teixeira", + "createdAt": 1652845188000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D60-08cd9719-77cb-48c0-8ad5-053e020d5d44", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D60", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845191000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D78-6bc66653-f00e-49d1-85a3-499e002a192e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D78", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845347000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D77-38511b2b-6b8f-480b-8e76-5dc2e9da28ea", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D77", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845324000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D92-8be5d4ee-0380-4dc4-814f-ebdaf9004ea4", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D92", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845439000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D90-4e6fe0eb-0b91-4b94-8c09-5c19c760eb6e", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D90", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845435000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D94-7f2a5979-5678-49ea-ab03-813fe135a99d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D94", + "sheet": "Talisman List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652845469000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D93-fdae69ed-036b-4bae-ace8-a9bffdcd178a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D93", + "sheet": "Talisman List" + } + }, + "text": "D", + "author": "person:Marco Teixeira", + "createdAt": 1652845466000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D70-707bfc78-0f70-4c2f-a8fe-946ffe8eb648", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D70", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845254000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D87-4cf33b9d-2fc8-4f24-bb3b-7a02d6439c6b", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D87", + "sheet": "Talisman List" + } + }, + "text": "G", + "author": "person:Marco Teixeira", + "createdAt": 1652845411000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D89-0e50d807-b43e-439f-b9af-1357d3ffa6d5", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D89", + "sheet": "Talisman List" + } + }, + "text": "Sh", + "author": "person:Marco Teixeira", + "createdAt": 1652845418000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-8-D88-fe74f67f-059c-4271-9a77-877e6ac66d16", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "D88", + "sheet": "Talisman List" + } + }, + "text": "H", + "author": "person:Marco Teixeira", + "createdAt": 1652845415000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-9-E99-ce0c819d-69d9-4b16-a785-0604674654ac", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "E99", + "sheet": "Key Items List" + } + }, + "text": "There are 3 Golden Seeds missing, there are 43 total (44 if you count the bugged one in Moghwin Palace). All are found under a Golden Sapling, their locations are 1) West of the Academy courtyard, 2) southwest of the Ravine-Veiled Village and 3) in Mountaintops East in a valley northeast of the Minor Erdtree.", + "author": "person:SunMar 2038", + "createdAt": 1651616632000, + "replies": [ + { + "id": "xt-9-E99-ce0c819d-69d9-4b16-a785-0604674654ac-r1", + "author": "person:Jacob Bacub", + "text": "Ah thank you. I felt like some were missing from the wiki. I will add those.", + "createdAt": 1651632768000 + }, + { + "id": "xt-9-E99-ce0c819d-69d9-4b16-a785-0604674654ac-r2", + "author": "person:SunMar 2038", + "text": "Yeah, I updated the wiki after figuring out which were missing there so Fextralife should be good now. Map Genie helped me figure out which were missing.", + "createdAt": 1651721630000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-9-E182-b1f0adaf-2f72-43e4-992e-492a859c7aea", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "E182", + "sheet": "Key Items List" + } + }, + "text": "There are 2 Lost Ashes missing, there are 14 total. The missing ones are one in Leyndell slums' sewer tunnels (not the one near the Sewer Gaol) and the other missing one is at the bottom of the Sellia Hideaway cave.", + "author": "person:SunMar 2038", + "createdAt": 1651608705000, + "replies": [ + { + "id": "xt-9-E182-b1f0adaf-2f72-43e4-992e-492a859c7aea-r1", + "author": "person:Jacob Bacub", + "text": "I knew I shouldn't have trusted the wiki with these counts. I should have looked how many I had in game. I have 28 from two playthroughs so that checks out. Thanks for the correction!\nMarked as resolved", + "createdAt": 1651633003000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-9-K2-8f2b3b8a-9cfd-4d2b-af83-e00b09d7b123", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "K2", + "sheet": "Key Items List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886190000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-9-K3-c21d1027-e455-4a57-b42e-54d2bfc79911", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "K3", + "sheet": "Key Items List" + } + }, + "text": "1", + "author": "person:REXUS", + "createdAt": 1725886199000, + "replies": [], + "resolved": false, + "history": [] + }, + { + "id": "xt-9-E166-8aae57e3-9561-4551-a08b-1d5627c2901a", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "E166", + "sheet": "Key Items List" + } + }, + "text": "There are two more Celestial Dew for a total of 10. There is a second one in Night's Sacred Ground and there also is one in Erdtree Sanctuary. I've updated Fextralife as well.", + "author": "person:SunMar 2038", + "createdAt": 1651605786000, + "replies": [ + { + "id": "xt-9-E166-8aae57e3-9561-4551-a08b-1d5627c2901a-r1", + "author": "person:Jacob Bacub", + "text": "Thanks for this, I will add those in.", + "createdAt": 1651632924000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-9-I2-fac32877-7d36-4140-b155-2446cee03db0", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "I2", + "sheet": "Key Items List" + } + }, + "text": "Does not highlight when checked.", + "author": "person:SunMar 2038", + "createdAt": 1651608849000, + "replies": [ + { + "id": "xt-9-I2-fac32877-7d36-4140-b155-2446cee03db0-r1", + "author": "person:Jacob Bacub", + "text": "Yeah I realized this a little while ago. You must have an older version copied if its not fixed for you. Thanks for pointing it out though.\nMarked as resolved", + "createdAt": 1651632872000 + }, + { + "id": "xt-9-I2-fac32877-7d36-4140-b155-2446cee03db0-r2", + "author": "person:SunMar 2038", + "text": "Ah great, thanks! I'll make a new copy for my next playthrough.\nRe-opened", + "createdAt": 1651721509000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-9-F183-23bdda20-4e7f-42e8-8009-1fb5b4debe0d", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "F183", + "sheet": "Key Items List" + } + }, + "text": "Marked as loot, but it's an item for purchase sold by the Imprisoned Merchant.", + "author": "person:SunMar 2038", + "createdAt": 1651608739000, + "replies": [ + { + "id": "xt-9-F183-23bdda20-4e7f-42e8-8009-1fb5b4debe0d-r1", + "author": "person:Jacob Bacub", + "text": "Thank you for this distinction.", + "createdAt": 1651633026000 + } + ], + "resolved": true, + "history": [] + }, + { + "id": "xt-9-C41-0987d928-538e-45e5-921c-e66a82f3ca8f", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C41", + "sheet": "Key Items List" + } + }, + "text": "D", + "author": "person:Daniel Broadbent", + "createdAt": 1671621731000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "xt-10-C4-d6a5e6f8-6b52-44d7-abbc-0f509e330879", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "selector": { + "kind": "cell", + "selector": { + "type": "CellSelector", + "cell": "C4", + "sheet": "Remembrance List" + } + }, + "text": "1", + "author": "person:Soheil Nabiloo", + "createdAt": 1722185994000, + "replies": [], + "resolved": true, + "history": [] + } + ] +} diff --git a/app/src/test/resources/doc-comments/golden/launch-brief.json b/app/src/test/resources/doc-comments/golden/launch-brief.json new file mode 100644 index 000000000..f62942d86 --- /dev/null +++ b/app/src/test/resources/doc-comments/golden/launch-brief.json @@ -0,0 +1,51 @@ +{ + "comments": [ + { + "id": "w-0", + "path": "docs/launch-brief.docx", + "selector": { + "kind": "text", + "selector": { + "type": "TextQuoteSelector", + "exact": "Move 30% of weekly active users to the new app within six weeks of launch.", + "prefix": "p for existing customers. Goals ", + "suffix": " Keep support tickets about the ", + "occurrence": 0 + } + }, + "text": "Is 30% realistic? The last launch reached 18% in the same window.", + "author": "person:Priya Shah", + "createdAt": 1790157600000, + "replies": [], + "resolved": true, + "history": [] + }, + { + "id": "w-1", + "path": "docs/launch-brief.docx", + "selector": { + "kind": "text", + "selector": { + "type": "TextQuoteSelector", + "exact": "Beta opens to 500 customers on March 10", + "prefix": "te below 200 per week. Timeline ", + "suffix": ", with the public launch on Apri", + "occurrence": 0 + } + }, + "text": "Legal needs the beta terms by March 3 at the latest.", + "author": "person:Priya Shah", + "createdAt": 1790157900000, + "replies": [ + { + "id": "w-1-r1", + "author": "person:Marcus Lee", + "text": "Confirmed with legal — the beta terms are ready as of this morning.", + "createdAt": 1790173200000 + } + ], + "resolved": false, + "history": [] + } + ] +} diff --git a/app/src/test/resources/doc-comments/golden/no-comments.json b/app/src/test/resources/doc-comments/golden/no-comments.json new file mode 100644 index 000000000..a0d0268f8 --- /dev/null +++ b/app/src/test/resources/doc-comments/golden/no-comments.json @@ -0,0 +1,3 @@ +{ + "comments": [] +} diff --git a/app/src/test/resources/doc-comments/golden/q3-sales-by-rep.json b/app/src/test/resources/doc-comments/golden/q3-sales-by-rep.json new file mode 100644 index 000000000..a0d0268f8 --- /dev/null +++ b/app/src/test/resources/doc-comments/golden/q3-sales-by-rep.json @@ -0,0 +1,3 @@ +{ + "comments": [] +} diff --git a/app/src/test/resources/doc-comments/golden/spanning-comment.json b/app/src/test/resources/doc-comments/golden/spanning-comment.json new file mode 100644 index 000000000..af32bc75c --- /dev/null +++ b/app/src/test/resources/doc-comments/golden/spanning-comment.json @@ -0,0 +1,24 @@ +{ + "comments": [ + { + "id": "w-5", + "path": "docs/spanning-comment.docx", + "selector": { + "kind": "text", + "selector": { + "type": "TextQuoteSelector", + "exact": "first half \nsecond half.", + "prefix": "Before the change: ", + "suffix": " After the note. ", + "occurrence": 0 + } + }, + "text": "Should this be one paragraph instead of two?", + "author": "person:Priya Shah", + "createdAt": 1790413200000, + "replies": [], + "resolved": false, + "history": [] + } + ] +} diff --git a/app/src/test/resources/doc-comments/golden/word365-realistic.json b/app/src/test/resources/doc-comments/golden/word365-realistic.json new file mode 100644 index 000000000..6ace551a1 --- /dev/null +++ b/app/src/test/resources/doc-comments/golden/word365-realistic.json @@ -0,0 +1,24 @@ +{ + "comments": [ + { + "id": "w-0", + "path": "docs/word365-realistic.docx", + "selector": { + "kind": "text", + "selector": { + "type": "TextQuoteSelector", + "exact": "Revenue grew across every region this quarter.", + "prefix": "External reference ", + "suffix": " The results look strong across ", + "occurrence": 0 + } + }, + "text": "Can we get the regional breakdown for this?", + "author": "person:Priya Shah", + "createdAt": 1789898400000, + "replies": [], + "resolved": false, + "history": [] + } + ] +} diff --git a/app/src/test/resources/doc-comments/json-sidecar/thread.json b/app/src/test/resources/doc-comments/json-sidecar/thread.json new file mode 100644 index 000000000..9ab4fed54 --- /dev/null +++ b/app/src/test/resources/doc-comments/json-sidecar/thread.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "comments": [ + { + "id": "c-fixture-0001", + "path": "docs/plan.md", + "selector": { + "kind": "text", + "selector": { + "type": "TextQuoteSelector", + "exact": "cut the onboarding step", + "prefix": "we should probably ", + "suffix": " before shipping", + "occurrence": 0 + } + }, + "text": "Can we cut this?", + "author": "person:Priya Shah", + "createdAt": 1758000000000, + "replies": [ + { + "id": "c-fixture-0001-r1", + "author": "user", + "text": "Agreed, cutting it.", + "createdAt": 1758000100000 + } + ], + "resolved": true, + "history": [ + { + "by": "user", + "at": 1758000200000, + "action": "resolved" + } + ] + } + ] +} diff --git a/app/src/test/resources/doc-comments/launch-brief.docx b/app/src/test/resources/doc-comments/launch-brief.docx new file mode 100644 index 000000000..0563ee444 Binary files /dev/null and b/app/src/test/resources/doc-comments/launch-brief.docx differ diff --git a/app/src/test/resources/doc-comments/no-comments.docx b/app/src/test/resources/doc-comments/no-comments.docx new file mode 100644 index 000000000..c12de5ca2 Binary files /dev/null and b/app/src/test/resources/doc-comments/no-comments.docx differ diff --git a/app/src/test/resources/doc-comments/q3-sales-by-rep.xlsx b/app/src/test/resources/doc-comments/q3-sales-by-rep.xlsx new file mode 100644 index 000000000..9c51f460a Binary files /dev/null and b/app/src/test/resources/doc-comments/q3-sales-by-rep.xlsx differ diff --git a/app/src/test/resources/doc-comments/spanning-comment.docx b/app/src/test/resources/doc-comments/spanning-comment.docx new file mode 100644 index 000000000..13e09533b Binary files /dev/null and b/app/src/test/resources/doc-comments/spanning-comment.docx differ diff --git a/app/src/test/resources/doc-comments/synthetic-worksheet-with-extlst.xlsx b/app/src/test/resources/doc-comments/synthetic-worksheet-with-extlst.xlsx new file mode 100644 index 000000000..9ad4d9e8a Binary files /dev/null and b/app/src/test/resources/doc-comments/synthetic-worksheet-with-extlst.xlsx differ diff --git a/app/src/test/resources/doc-comments/word365-realistic.docx b/app/src/test/resources/doc-comments/word365-realistic.docx new file mode 100644 index 000000000..2156e752e Binary files /dev/null and b/app/src/test/resources/doc-comments/word365-realistic.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/add-docling.json b/app/src/test/resources/doc-comments/write-golden/add-docling.json new file mode 100644 index 000000000..26b49d69c --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/add-docling.json @@ -0,0 +1,19 @@ +{ + "fixture": "docling-xlsx-comments.xlsx", + "path": "reports/docling-xlsx-comments.xlsx", + "steps": [ + { + "op": "add", + "args": { + "cell": "C1", + "text": "Which release of docling generated this?", + "author": "user" + }, + "result": { + "ok": true, + "id": "xt-1-C1-E6FF9B22-E3EE-40BB-BE77-2BF3428F14FE", + "text": "Which release of docling generated this?" + } + } + ] +} diff --git a/app/src/test/resources/doc-comments/write-golden/add-docling.xlsx b/app/src/test/resources/doc-comments/write-golden/add-docling.xlsx new file mode 100644 index 000000000..d6c1f7f86 Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/add-docling.xlsx differ diff --git a/app/src/test/resources/doc-comments/write-golden/add-launch-brief.docx b/app/src/test/resources/doc-comments/write-golden/add-launch-brief.docx new file mode 100644 index 000000000..0ddd9a76b Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/add-launch-brief.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/add-launch-brief.json b/app/src/test/resources/doc-comments/write-golden/add-launch-brief.json new file mode 100644 index 000000000..9d5994b26 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/add-launch-brief.json @@ -0,0 +1,14 @@ +{ + "fixture": "launch-brief.docx", + "path": "docs/launch-brief.docx", + "op": "add", + "args": { + "exact": "Marketing emails go out the same morning as the public launch.", + "text": "Confirm the send time with marketing.", + "author": "user" + }, + "result": { + "ok": true, + "id": "w-3" + } +} diff --git a/app/src/test/resources/doc-comments/write-golden/add-spanning-comment.docx b/app/src/test/resources/doc-comments/write-golden/add-spanning-comment.docx new file mode 100644 index 000000000..1c68c7b67 Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/add-spanning-comment.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/add-spanning-comment.json b/app/src/test/resources/doc-comments/write-golden/add-spanning-comment.json new file mode 100644 index 000000000..a4ebb9e06 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/add-spanning-comment.json @@ -0,0 +1,14 @@ +{ + "fixture": "spanning-comment.docx", + "path": "docs/spanning-comment.docx", + "op": "add", + "args": { + "exact": "half \nsecond", + "text": "A second, independent multi-paragraph comment.", + "author": "user" + }, + "result": { + "ok": true, + "id": "w-6" + } +} diff --git a/app/src/test/resources/doc-comments/write-golden/add-word365-realistic.docx b/app/src/test/resources/doc-comments/write-golden/add-word365-realistic.docx new file mode 100644 index 000000000..efe198fb4 Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/add-word365-realistic.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/add-word365-realistic.json b/app/src/test/resources/doc-comments/write-golden/add-word365-realistic.json new file mode 100644 index 000000000..2f7fcebd6 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/add-word365-realistic.json @@ -0,0 +1,14 @@ +{ + "fixture": "word365-realistic.docx", + "path": "docs/word365-realistic.docx", + "op": "add", + "args": { + "exact": "The results look strong across", + "text": "Which regions specifically?", + "author": "assistant" + }, + "result": { + "ok": true, + "id": "w-1" + } +} diff --git a/app/src/test/resources/doc-comments/write-golden/delete-last-comment-fresh.json b/app/src/test/resources/doc-comments/write-golden/delete-last-comment-fresh.json new file mode 100644 index 000000000..c268a6a4d --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/delete-last-comment-fresh.json @@ -0,0 +1,28 @@ +{ + "fixture": "fresh-single-comment.xlsx", + "path": "fresh-single-comment.xlsx", + "steps": [ + { + "op": "add", + "args": { + "cell": "A1", + "text": "only comment", + "author": "user" + }, + "result": { + "ok": true, + "id": "xt-1-A1-E1D9B337-F312-408B-9F98-C2B653E08D90", + "text": "only comment" + } + }, + { + "op": "delete", + "args": { + "id": "xt-1-A1-E1D9B337-F312-408B-9F98-C2B653E08D90" + }, + "result": { + "ok": true + } + } + ] +} diff --git a/app/src/test/resources/doc-comments/write-golden/delete-last-comment-fresh.xlsx b/app/src/test/resources/doc-comments/write-golden/delete-last-comment-fresh.xlsx new file mode 100644 index 000000000..bfbf159dc Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/delete-last-comment-fresh.xlsx differ diff --git a/app/src/test/resources/doc-comments/write-golden/delete-reply-docling.json b/app/src/test/resources/doc-comments/write-golden/delete-reply-docling.json new file mode 100644 index 000000000..6341b57e4 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/delete-reply-docling.json @@ -0,0 +1,16 @@ +{ + "fixture": "docling-xlsx-comments.xlsx", + "path": "reports/docling-xlsx-comments.xlsx", + "steps": [ + { + "op": "delete-reply", + "args": { + "id": "xt-1-F7-04C1C54B-2744-A647-93D1-A99C27C7EFDC", + "replyId": "xt-1-F7-04C1C54B-2744-A647-93D1-A99C27C7EFDC-r1" + }, + "result": { + "ok": true + } + } + ] +} diff --git a/app/src/test/resources/doc-comments/write-golden/delete-reply-docling.xlsx b/app/src/test/resources/doc-comments/write-golden/delete-reply-docling.xlsx new file mode 100644 index 000000000..24af76d20 Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/delete-reply-docling.xlsx differ diff --git a/app/src/test/resources/doc-comments/write-golden/delete-reply-launch-brief.docx b/app/src/test/resources/doc-comments/write-golden/delete-reply-launch-brief.docx new file mode 100644 index 000000000..b14f4f512 Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/delete-reply-launch-brief.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/delete-reply-launch-brief.json b/app/src/test/resources/doc-comments/write-golden/delete-reply-launch-brief.json new file mode 100644 index 000000000..b5afb2262 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/delete-reply-launch-brief.json @@ -0,0 +1,12 @@ +{ + "fixture": "launch-brief.docx", + "path": "docs/launch-brief.docx", + "op": "delete-reply", + "args": { + "id": "w-1", + "replyId": "w-1-r1" + }, + "result": { + "ok": true + } +} diff --git a/app/src/test/resources/doc-comments/write-golden/delete-thread-docling.json b/app/src/test/resources/doc-comments/write-golden/delete-thread-docling.json new file mode 100644 index 000000000..fc25c9b44 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/delete-thread-docling.json @@ -0,0 +1,15 @@ +{ + "fixture": "docling-xlsx-comments.xlsx", + "path": "reports/docling-xlsx-comments.xlsx", + "steps": [ + { + "op": "delete", + "args": { + "id": "xt-1-F7-04C1C54B-2744-A647-93D1-A99C27C7EFDC" + }, + "result": { + "ok": true + } + } + ] +} diff --git a/app/src/test/resources/doc-comments/write-golden/delete-thread-docling.xlsx b/app/src/test/resources/doc-comments/write-golden/delete-thread-docling.xlsx new file mode 100644 index 000000000..da9ec3728 Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/delete-thread-docling.xlsx differ diff --git a/app/src/test/resources/doc-comments/write-golden/delete-thread-launch-brief.docx b/app/src/test/resources/doc-comments/write-golden/delete-thread-launch-brief.docx new file mode 100644 index 000000000..9ba88478d Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/delete-thread-launch-brief.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/delete-thread-launch-brief.json b/app/src/test/resources/doc-comments/write-golden/delete-thread-launch-brief.json new file mode 100644 index 000000000..aa95e0ea4 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/delete-thread-launch-brief.json @@ -0,0 +1,11 @@ +{ + "fixture": "launch-brief.docx", + "path": "docs/launch-brief.docx", + "op": "delete", + "args": { + "id": "w-1" + }, + "result": { + "ok": true + } +} diff --git a/app/src/test/resources/doc-comments/write-golden/edit-docling.json b/app/src/test/resources/doc-comments/write-golden/edit-docling.json new file mode 100644 index 000000000..e2185fa48 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/edit-docling.json @@ -0,0 +1,17 @@ +{ + "fixture": "docling-xlsx-comments.xlsx", + "path": "reports/docling-xlsx-comments.xlsx", + "steps": [ + { + "op": "edit", + "args": { + "id": "xt-1-G12-3A26E9AE-8B38-864D-BAF4-BA5D9C6E1DA4", + "text": "Edited: confirmed against the vendor spec sheet." + }, + "result": { + "ok": true, + "text": "Edited: confirmed against the vendor spec sheet." + } + } + ] +} diff --git a/app/src/test/resources/doc-comments/write-golden/edit-docling.xlsx b/app/src/test/resources/doc-comments/write-golden/edit-docling.xlsx new file mode 100644 index 000000000..afb22502f Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/edit-docling.xlsx differ diff --git a/app/src/test/resources/doc-comments/write-golden/edit-launch-brief.docx b/app/src/test/resources/doc-comments/write-golden/edit-launch-brief.docx new file mode 100644 index 000000000..9091c198e Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/edit-launch-brief.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/edit-launch-brief.json b/app/src/test/resources/doc-comments/write-golden/edit-launch-brief.json new file mode 100644 index 000000000..fe98fcef8 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/edit-launch-brief.json @@ -0,0 +1,13 @@ +{ + "fixture": "launch-brief.docx", + "path": "docs/launch-brief.docx", + "op": "edit", + "args": { + "id": "w-0", + "text": "Edited: is 30% still realistic after the beta feedback call?" + }, + "result": { + "ok": true, + "text": "Edited: is 30% still realistic after the beta feedback call?" + } +} diff --git a/app/src/test/resources/doc-comments/write-golden/edit-reply-docling.json b/app/src/test/resources/doc-comments/write-golden/edit-reply-docling.json new file mode 100644 index 000000000..9dddce1e8 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/edit-reply-docling.json @@ -0,0 +1,23 @@ +{ + "fixture": "docling-xlsx-comments.xlsx", + "path": "reports/docling-xlsx-comments.xlsx", + "steps": [ + { + "op": "edit-reply", + "args": { + "id": "xt-1-F7-04C1C54B-2744-A647-93D1-A99C27C7EFDC", + "replyId": "xt-1-F7-04C1C54B-2744-A647-93D1-A99C27C7EFDC-r1", + "text": "Edited: it dropped further after the audit, actually." + }, + "result": { + "ok": true, + "reply": { + "id": "xt-1-F7-04C1C54B-2744-A647-93D1-A99C27C7EFDC-r1", + "author": "person:Marcus Sterling (MS)", + "text": "Edited: it dropped further after the audit, actually.", + "createdAt": 1781828152310 + } + } + } + ] +} diff --git a/app/src/test/resources/doc-comments/write-golden/edit-reply-docling.xlsx b/app/src/test/resources/doc-comments/write-golden/edit-reply-docling.xlsx new file mode 100644 index 000000000..7442bf0bf Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/edit-reply-docling.xlsx differ diff --git a/app/src/test/resources/doc-comments/write-golden/edit-reply-launch-brief.docx b/app/src/test/resources/doc-comments/write-golden/edit-reply-launch-brief.docx new file mode 100644 index 000000000..7c65841cf Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/edit-reply-launch-brief.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/edit-reply-launch-brief.json b/app/src/test/resources/doc-comments/write-golden/edit-reply-launch-brief.json new file mode 100644 index 000000000..d376d4049 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/edit-reply-launch-brief.json @@ -0,0 +1,19 @@ +{ + "fixture": "launch-brief.docx", + "path": "docs/launch-brief.docx", + "op": "edit-reply", + "args": { + "id": "w-1", + "replyId": "w-1-r1", + "text": "Edited: confirmed with legal this afternoon, terms are final." + }, + "result": { + "ok": true, + "reply": { + "id": "w-1-r1", + "author": "person:Marcus Lee", + "text": "Edited: confirmed with legal this afternoon, terms are final.", + "createdAt": 1790173200000 + } + } +} diff --git a/app/src/test/resources/doc-comments/write-golden/elden-sequence.json b/app/src/test/resources/doc-comments/write-golden/elden-sequence.json new file mode 100644 index 000000000..23e244dd0 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/elden-sequence.json @@ -0,0 +1,67 @@ +{ + "fixture": "elden-ring-completionist-checklist.xlsx", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "steps": [ + { + "op": "add", + "args": { + "cell": "ZZ999", + "sheet": "Sorceries & Incantations List", + "text": "T21 golden-fixture parity check — does this round-trip identically on both platforms?", + "author": "user" + }, + "result": { + "ok": true, + "id": "xt-4-ZZ999-FE55FBFD-9BF5-4D90-959E-71F78A86F978", + "text": "T21 golden-fixture parity check — does this round-trip identically on both platforms?" + } + }, + { + "op": "reply", + "args": { + "id": "xt-4-ZZ999-FE55FBFD-9BF5-4D90-959E-71F78A86F978", + "text": "Confirmed on desktop — checking Android next.", + "author": "assistant" + }, + "result": { + "ok": true, + "reply": { + "id": "xt-4-ZZ999-FE55FBFD-9BF5-4D90-959E-71F78A86F978-r1", + "author": "assistant", + "text": "Confirmed on desktop — checking Android next.", + "createdAt": 1790568918070 + } + } + }, + { + "op": "resolve", + "args": { + "id": "xt-4-ZZ999-FE55FBFD-9BF5-4D90-959E-71F78A86F978" + }, + "result": { + "ok": true + } + }, + { + "op": "reopen", + "args": { + "id": "xt-4-ZZ999-FE55FBFD-9BF5-4D90-959E-71F78A86F978" + }, + "result": { + "ok": true + } + }, + { + "op": "move", + "args": { + "id": "xt-4-ZZ999-FE55FBFD-9BF5-4D90-959E-71F78A86F978", + "newCell": "ZZ998", + "newSheet": "Remembrance List" + }, + "result": { + "ok": true, + "id": "xt-10-ZZ998-FE55FBFD-9BF5-4D90-959E-71F78A86F978" + } + } + ] +} diff --git a/app/src/test/resources/doc-comments/write-golden/elden-sequence.xlsx b/app/src/test/resources/doc-comments/write-golden/elden-sequence.xlsx new file mode 100644 index 000000000..f1da5574c Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/elden-sequence.xlsx differ diff --git a/app/src/test/resources/doc-comments/write-golden/move-docling.json b/app/src/test/resources/doc-comments/write-golden/move-docling.json new file mode 100644 index 000000000..0c30b3ef1 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/move-docling.json @@ -0,0 +1,17 @@ +{ + "fixture": "docling-xlsx-comments.xlsx", + "path": "reports/docling-xlsx-comments.xlsx", + "steps": [ + { + "op": "move", + "args": { + "id": "xt-1-F7-04C1C54B-2744-A647-93D1-A99C27C7EFDC", + "newCell": "H20" + }, + "result": { + "ok": true, + "id": "xt-1-H20-04C1C54B-2744-A647-93D1-A99C27C7EFDC" + } + } + ] +} diff --git a/app/src/test/resources/doc-comments/write-golden/move-docling.xlsx b/app/src/test/resources/doc-comments/write-golden/move-docling.xlsx new file mode 100644 index 000000000..82d37016f Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/move-docling.xlsx differ diff --git a/app/src/test/resources/doc-comments/write-golden/move-launch-brief.docx b/app/src/test/resources/doc-comments/write-golden/move-launch-brief.docx new file mode 100644 index 000000000..e3b0d6543 Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/move-launch-brief.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/move-launch-brief.json b/app/src/test/resources/doc-comments/write-golden/move-launch-brief.json new file mode 100644 index 000000000..63dc74b8c --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/move-launch-brief.json @@ -0,0 +1,12 @@ +{ + "fixture": "launch-brief.docx", + "path": "docs/launch-brief.docx", + "op": "move", + "args": { + "id": "w-1", + "newExact": "Keep support tickets about the update below 200 per week." + }, + "result": { + "ok": true + } +} diff --git a/app/src/test/resources/doc-comments/write-golden/move-word365-realistic.docx b/app/src/test/resources/doc-comments/write-golden/move-word365-realistic.docx new file mode 100644 index 000000000..5d7ce22b4 Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/move-word365-realistic.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/move-word365-realistic.json b/app/src/test/resources/doc-comments/write-golden/move-word365-realistic.json new file mode 100644 index 000000000..61ec1edac --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/move-word365-realistic.json @@ -0,0 +1,12 @@ +{ + "fixture": "word365-realistic.docx", + "path": "docs/word365-realistic.docx", + "op": "move", + "args": { + "id": "w-0", + "newExact": "well ahead of plan" + }, + "result": { + "ok": true + } +} diff --git a/app/src/test/resources/doc-comments/write-golden/reopen-launch-brief.docx b/app/src/test/resources/doc-comments/write-golden/reopen-launch-brief.docx new file mode 100644 index 000000000..99c731083 Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/reopen-launch-brief.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/reopen-launch-brief.json b/app/src/test/resources/doc-comments/write-golden/reopen-launch-brief.json new file mode 100644 index 000000000..788d660ab --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/reopen-launch-brief.json @@ -0,0 +1,11 @@ +{ + "fixture": "launch-brief.docx", + "path": "docs/launch-brief.docx", + "op": "reopen", + "args": { + "id": "w-0" + }, + "result": { + "ok": true + } +} diff --git a/app/src/test/resources/doc-comments/write-golden/reply-launch-brief.docx b/app/src/test/resources/doc-comments/write-golden/reply-launch-brief.docx new file mode 100644 index 000000000..32053346a Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/reply-launch-brief.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/reply-launch-brief.json b/app/src/test/resources/doc-comments/write-golden/reply-launch-brief.json new file mode 100644 index 000000000..8fb9d2b53 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/reply-launch-brief.json @@ -0,0 +1,13 @@ +{ + "fixture": "launch-brief.docx", + "path": "docs/launch-brief.docx", + "op": "reply", + "args": { + "id": "w-1", + "text": "Sounds good, thanks both.", + "author": "user" + }, + "result": { + "ok": true + } +} diff --git a/app/src/test/resources/doc-comments/write-golden/resolve-b19-sibling.json b/app/src/test/resources/doc-comments/write-golden/resolve-b19-sibling.json new file mode 100644 index 000000000..7309438a3 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/resolve-b19-sibling.json @@ -0,0 +1,15 @@ +{ + "fixture": "elden-ring-completionist-checklist.xlsx", + "path": "reports/elden-ring-completionist-checklist.xlsx", + "steps": [ + { + "op": "resolve", + "args": { + "id": "xt-1-B19-11a0fac8-69fe-49e4-a4d6-f19627c11856" + }, + "result": { + "ok": true + } + } + ] +} diff --git a/app/src/test/resources/doc-comments/write-golden/resolve-b19-sibling.xlsx b/app/src/test/resources/doc-comments/write-golden/resolve-b19-sibling.xlsx new file mode 100644 index 000000000..d31b9db4e Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/resolve-b19-sibling.xlsx differ diff --git a/app/src/test/resources/doc-comments/write-golden/resolve-spanning-comment.docx b/app/src/test/resources/doc-comments/write-golden/resolve-spanning-comment.docx new file mode 100644 index 000000000..807f8904f Binary files /dev/null and b/app/src/test/resources/doc-comments/write-golden/resolve-spanning-comment.docx differ diff --git a/app/src/test/resources/doc-comments/write-golden/resolve-spanning-comment.json b/app/src/test/resources/doc-comments/write-golden/resolve-spanning-comment.json new file mode 100644 index 000000000..c9fc6d9a8 --- /dev/null +++ b/app/src/test/resources/doc-comments/write-golden/resolve-spanning-comment.json @@ -0,0 +1,11 @@ +{ + "fixture": "spanning-comment.docx", + "path": "docs/spanning-comment.docx", + "op": "resolve", + "args": { + "id": "w-5" + }, + "result": { + "ok": true + } +} diff --git a/desktop/.oxlintrc.design.json b/desktop/.oxlintrc.design.json index f4317fb03..a9da36378 100644 --- a/desktop/.oxlintrc.design.json +++ b/desktop/.oxlintrc.design.json @@ -41,7 +41,16 @@ { "files": ["src/renderer/**/*.tsx"], "rules": { - "shadcn/no-restyle": ["warn", { "allow": ["layout"] }], + // contracts: solid-accent is a real, globals.css-defined class (opaque + // background over wallpaper themes, review deck R-5) — not an + // arbitrary restyle — so it's allowed on + ); +} + // Reveal-in-folder glyph — folder + eye at the corner (approved mockup 2-prime). // Lives outside PATHS: the eye uses a thinner stroke (1.5) and a FILLED pupil, // which the uniform-stroke Ic helper cannot express. @@ -400,6 +428,14 @@ export const SessionDrawer = React.memo(function SessionDrawer({ sessionId, proj // this ref + mirrors its state so the toolbar can swap pencil ↔ save/cancel. const editRef = useRef(null); const [editState, setEditState] = useState<{ isEditable: boolean; editing: boolean }>({ isEditable: false, editing: false }); + const [commentsState, setCommentsState] = useState({ available: false, active: false, count: 0, paneVisible: false, actionsRight: 8, actionsWidth: 240, paneLeft: 256, actionsBottom: 36 }); + // Entering Comments mode folds the file list away however it was entered + // (the Comments button, clicking a highlight, a sent pill) — the floating + // comment actions share Edit's hide-while-the-list-is-open rule, so with the + // list open "Ask Your Assistant" would be hidden the moment the mode opened. + useEffect(() => { + if (commentsState.active) setListOpen(false); + }, [commentsState.active]); // D3: every navigation away from a dirty editor goes through this guard // (Save / Discard / Cancel dialog) instead of silently discarding the draft. const { guard: guardUnsaved, dialog: unsavedDialog } = useUnsavedGuard( @@ -1240,6 +1276,7 @@ export const SessionDrawer = React.memo(function SessionDrawer({ sessionId, proj onDiskRead={applyDiskRead} controlsInHeader onEditStateChange={setEditState} + onCommentsStateChange={setCommentsState} /> {/* Floating Edit ↔ Save cluster, bottom-right of the doc pane. @@ -1247,14 +1284,73 @@ export const SessionDrawer = React.memo(function SessionDrawer({ sessionId, proj and back OUT when the list reopens — kept mounted so both directions animate. While EDITING it stays visible regardless, so Save can never be hidden by opening the list. */} - {active && (editState.editing || editState.isEditable) && ( + {/* Doc comments (round 5): the cluster is now a right-aligned + column. Bottom row: [Comments] [Edit]. In Comments mode, + "Show resolved" and "Ask Your Assistant" float above that row, + over the comment column — no bar or panel of their own + (Destin: "they should float over the same … panel"). The + column itself ignores the pointer so the gaps between pills + never block the document; each pill opts back in. */} + {/* Round 6 (Destin: "it should pop in and disappear the same + way the edit button does"): the WHOLE cluster now shares + Edit's pop — in when the file list folds away, out when it + reopens (still always shown while editing, so Save can't + hide). `inert` while faded, because each pill re-enables + pointer events for itself and would otherwise stay + clickable while invisible. Clicking Comments folds the list + away, like clicking Edit does, so the buttons Comments mode + needs are on screen the moment it opens. */} + {/* Ask Your Assistant at the foot + of the comment column — w-60 = the margin's card width, right-2 + = the cards' 8px inset from the edge (measured). Round 10 + (Destin: "should only appear when the full pane is actually + visible, and should disappear if the pane is hidden"): shown + only while the full column is on screen — not with the file + list's pop-in rule, and not over the collapsed marker rail. */} + {active && commentsState.paneVisible && ( + // Lined up with the cards' measured edges (ActiveArtifactView, + // rounds 11/15) — right with or without a scrollbar. +
+ {/* After sending, leave Comments mode so the chat — and the + message just sent — is in view; in the (auto-)expanded + pane the send happened out of sight and the button + "doesn't seem clickable" (review deck R-5). */} + editRef.current?.toggleComments()} projectRoot={projectRoot} /> +
+ )} + {active && (
+ {/* Comments + Edit sit over the DOCUMENT: in Comments mode they + move left past the 256px comment column (right-68 = 16px + clearance + 256px), otherwise their usual 16px from the edge. */} + {/* In Comments mode: 16px clear of the comment pane's measured + left edge (rounds 13/15). */} +
+ {commentsState.available && !editState.editing && ( + editRef.current?.toggleComments()} + /> + )} + {/* Round 7 (Destin: "the button should stay in the same spot next + to edit when selected"): Edit stays visible in Comments mode — + hiding it slid the Comments button sideways on every toggle. */} + {(editState.editing || editState.isEditable) && ( +
{editState.editing ? ( <> )}
+ )} +
+
)} {/* metadata strip — bottom of the DOC column (not a full-width row up top), so it shares the document's width and expands/shrinks with diff --git a/desktop/src/renderer/components/UserMessage.tsx b/desktop/src/renderer/components/UserMessage.tsx index c40c2b0c6..35b4e494e 100644 --- a/desktop/src/renderer/components/UserMessage.tsx +++ b/desktop/src/renderer/components/UserMessage.tsx @@ -5,6 +5,13 @@ import { splitFlowingKeywords } from './FlowingKeywords'; import { formatBubbleTime } from '../utils/format-time'; import { detectFilepaths } from '../hooks/useInlineFilepathDetector'; import { FilepathToken } from './FilepathToken'; +// Round 2 (Destin): a reference token typed via "Ask about this" / "Send to +// assistant" rides in message.content as an invisible marker (compose-ref.ts) +// — decode it back into the SAME pill the composer showed, inline in the +// sentence, so a reference reads identically before and after sending. +import { splitComposeRefs, jumpToRef, type ComposeRef } from './context-menu/compose-ref'; +import { useOpenFilepath } from '../hooks/useOpenFilepath'; +import { TokenPill } from './comments/TokenPill'; interface Props { message: ChatMessage; @@ -25,6 +32,9 @@ function renderTextRun(text: string, keyPrefix: string): React.ReactNode[] { } export default React.memo(function UserMessage({ message, sessionId, showTimestamps }: Props) { + // A chip click opens its file when it isn't already open, then jumps to + // the source text (compose-ref.ts "Chip ↔ source text"). + const openFile = useOpenFilepath(sessionId); const content = message.content; // Attached files: message.attachments carries the EXACT picker paths (which @@ -44,28 +54,41 @@ export default React.memo(function UserMessage({ message, sessionId, showTimesta if (i < attachments.length - 1 || text.length > 0) attachmentPills.push(' '); } - // Detect filepaths in the (remaining) typed text and render each as a - // clickable pill that opens in the artifact viewer, same as assistant - // messages. Non-path spans keep the flowing-keyword + URL-link treatment. - // NOTE: this covers the LIVE bubble; a reloaded-from-transcript message - // loses attachment paths (the transcript stores images as blocks, not - // paths), so pills there fall back to plain text. - const matches = detectFilepaths(text); - - let body: React.ReactNode[]; - if (matches.length === 0) { - body = renderTextRun(text, 't'); - } else { - body = []; + // Detect filepaths in a plain-text segment and render each as a clickable + // pill that opens in the artifact viewer, same as assistant messages. + // Non-path spans keep the flowing-keyword + URL-link treatment. NOTE: this + // covers the LIVE bubble; a reloaded-from-transcript message loses + // attachment paths (the transcript stores images as blocks, not paths), so + // pills there fall back to plain text. + function renderProseSegment(segment: string, keyPrefix: string): React.ReactNode[] { + const matches = detectFilepaths(segment); + if (matches.length === 0) return renderTextRun(segment, keyPrefix); + const out: React.ReactNode[] = []; let cursor = 0; matches.forEach((m, mi) => { - if (m.start > cursor) body.push(...renderTextRun(text.slice(cursor, m.start), `t${mi}`)); - body.push(); + if (m.start > cursor) out.push(...renderTextRun(segment.slice(cursor, m.start), `${keyPrefix}t${mi}`)); + out.push(); cursor = m.end; }); - if (cursor < text.length) body.push(...renderTextRun(text.slice(cursor), 'tend')); + if (cursor < segment.length) out.push(...renderTextRun(segment.slice(cursor), `${keyPrefix}end`)); + return out; + } + + // Reference tokens (compose-ref.ts) split out from the REMAINING text — + // they can sit anywhere, interleaved with ordinary words and filepaths. + // The pill uses tone="on-accent": this bubble is bg-accent, and the + // composer's neutral pill (tuned for a panel background) would sit at low + // contrast on it — same reasoning as Button's own on-accent variant. + const body: React.ReactNode[] = [...attachmentPills]; + const segs = splitComposeRefs(text); + const pill = (ref: ComposeRef, key: string) => ( + jumpToRef(r, openFile)} tone="on-accent" /> + ); + for (let i = 0; i < segs.length; i++) { + const seg = segs[i]; + if (seg.type === 'ref') body.push(pill(seg.ref, `ref-${seg.ref.id}-${i}`)); + else body.push(...renderProseSegment(seg.value, `s${i}-`)); } - body = [...attachmentPills, ...body]; return (
diff --git a/desktop/src/renderer/components/artifact-views/ActiveArtifactView.tsx b/desktop/src/renderer/components/artifact-views/ActiveArtifactView.tsx index c6dab5d7a..ba53cdabc 100644 --- a/desktop/src/renderer/components/artifact-views/ActiveArtifactView.tsx +++ b/desktop/src/renderer/components/artifact-views/ActiveArtifactView.tsx @@ -2,7 +2,7 @@ // Extracted from SessionDrawer.tsx (Task 7.2) so both SessionDrawer and ProjectView // can use it identically without duplicating the edit state + conflict-detection logic. import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle, Suspense } from 'react'; -import { getViewer, getEditViewer, rendersFromBytesOnly, isTextContentViewer } from './RendererRegistry'; +import { getViewer, getEditViewer, rendersFromBytesOnly, isTextContentViewer, isCodeEditorViewer, isCommentableBinaryViewer } from './RendererRegistry'; import { PartialFileBanner } from './PartialFileBanner'; import { canEditArtifact } from './edit-permission'; import { ViewerErrorBoundary } from './ViewerErrorBoundary'; @@ -14,6 +14,24 @@ import { LoadingState, ErrorState } from '../ui/states'; import { RemoteFileCard } from './RemoteFileCard'; import { describeReadError } from './read-error-copy'; import { isRemoteMode } from '../../platform'; +// Doc comments (round 2, Destin): Reading mode (default) vs Comments mode +// (the comment panel). Every host draws the Comments button itself (the file +// drawer as a floating pill beside Edit, the Projects screen as a header tool +// beside Edit) and calls toggleComments(). +import { CommentsActionsInPaneContext, CommentsCloseContext } from '../comments/CommentsPaneFrame'; +import { CodeCommentsRail } from '../comments/CodeCommentsRail'; +import { CodeCommentPopover } from '../comments/CodeCommentPopover'; +import { requestThreadAgain } from '../comments/CommentsMargin'; +import { useDocComments } from '../../state/doc-comments-store'; +import { useNarrowByRef } from '../../hooks/use-container-narrow'; +// Round 3: Comments mode needs margin-card room the drawer's DEFAULT width +// doesn't have (see the effect below's own WHY) — reuses the drawer's +// existing Expand control's shared state rather than inventing a second +// "wide" concept for this one mode. Optional variants: several existing +// ActiveArtifactView tests render it with no ArtifactProvider ancestor at +// all (it didn't read this store before), and the throwing hooks would take +// down every one of them rather than just no-op the auto-expand. +import { useArtifactSelectorOptional, useArtifactDispatchOptional } from '../../state/ArtifactContext'; /** Absolute on-disk path of an artifact — the same join SessionDrawer and * FilesTab make for Copy path, so Download asks the host for the same file. */ @@ -76,6 +94,32 @@ export interface ActiveArtifactHandle { * Retries briefly — the lazy CM6 chunk may still be mounting when a search * result opens a file. No-op for non-code viewers. */ revealLine(line: number): void; + /** Switch between Reading and Comments mode — driven by the host header's + * Comments button (round 4), paired with onCommentsStateChange. */ + toggleComments(): void; +} + +/** What the host header needs to draw the Comments button. */ +export interface CommentsHeaderState { + /** False on viewers with no text to anchor to (images, PDFs…) and while editing. */ + available: boolean; + active: boolean; + count: number; + /** The full comment column is on screen — Comments mode AND wide enough + * for the margin (not collapsed to its marker rail). */ + paneVisible: boolean; + /** Where the comment cards sit, measured from this view's right edge: + * `actionsRight`/`actionsWidth` line the floating comment actions up with + * the cards; `paneLeft` is the comment pane's outer left edge, so + * Comments/Edit can clear it. Measured rather than assumed because the + * pane's framing (round 15), its scrollbar and its padding all move them. */ + actionsRight: number; + actionsWidth: number; + paneLeft: number; + /** Distance from the bottom of the host's positioning box to 8px above the + * pane's bottom edge — so the floating actions keep the cards' 8px inset + * from a rounded panel's bottom border too (round 16). */ + actionsBottom: number; } /** Metadata from the artifacts:get response that content alone cannot carry — @@ -140,6 +184,13 @@ export interface ActiveArtifactViewProps { controlsInHeader?: boolean; // Fires whenever editability / edit-mode changes so the host header can update. onEditStateChange?: (s: { isEditable: boolean; editing: boolean }) => void; + /** Fires when the Comments button's state changes; the host draws the + * button (and the floating actions, unless commentsActionsInPane). */ + onCommentsStateChange?: (s: CommentsHeaderState) => void; + /** The host has no floating button cluster (the Projects screen's file + * overlay): Ask Your Assistant then floats inside the comment panel, and + * `beforeAsk` runs before it sends (see CommentsActionsInPaneContext). */ + commentsActionsInPane?: React.ContextType; /** Host's Ctrl+F bar is open — forwarded so a viewer can move its own floating * controls out from under it. */ findBarOpen?: boolean; @@ -147,7 +198,7 @@ export interface ActiveArtifactViewProps { export const ActiveArtifactView = forwardRef(function ActiveArtifactView({ artifact, content, contentInfo, contentState, onRetryRead, projectRoot, projectId, projectName, sessionId, onContentChange, onDiskRead, - controlsInHeader = false, onEditStateChange, findBarOpen = false, + controlsInHeader = false, onEditStateChange, onCommentsStateChange, commentsActionsInPane, findBarOpen = false, }, ref) { // Legacy default: a caller that doesn't thread contentState keeps the OLD // semantics (null content = missing) rather than silently losing the @@ -487,6 +538,7 @@ export const ActiveArtifactView = forwardRef setCommentsMode((m) => (m === 'comments' ? 'reading' : 'comments')), }), [isEditable, editing, dirty, handleStartEdit, handleSave, handleCancel]); // Desktop app-quit / window-close guard while dirty (D3). Android never @@ -533,6 +585,154 @@ export const ActiveArtifactView = forwardRef('reading'); + const [focusThreadId, setFocusThreadId] = useState(undefined); + useEffect(() => { setCommentsMode('reading'); setFocusThreadId(undefined); }, [artifact.id]); + // WHY read the store here too (Comments mode/ReadingHighlights each read + // it independently): "Open in comments" on a RESOLVED thread must reveal + // it — Comments mode hides resolved by default, and jumping to a thread + // nobody can see would look like the link did nothing. + const { comments: pathComments, setShowResolved: setPathShowResolved } = useDocComments(artifact.path, projectRoot); + // The Comments button's number counts OPEN comments only — "Comments 4" + // with two already resolved read as four things still waiting on you. + const openCount = pathComments.filter((c) => !c.resolved).length; + // Rounds 11 + 15: measure where the comment cards actually are. The host + // positions its floating buttons from this view's right edge, and the + // cards' distance from that edge depends on the pane's framing, its + // padding and whether its list shows a scrollbar — so it is measured, not + // assumed. [data-comments-list] is the padded card list (cards sit 8px + // inside it; its box already excludes the list's own scrollbar); + // [data-comments-pane] is the pane's outer edge. + const [paneGeom, setPaneGeom] = useState({ actionsRight: 8, actionsWidth: 240, paneLeft: 256, actionsBottom: 36 }); + useEffect(() => { + if (commentsMode !== 'comments') return; + const root = rootRef.current; + let ro: ResizeObserver | null = null; + // The pane mounts with the viewer (a lazy chunk): look on the next frame. + const raf = requestAnimationFrame(() => { + const list = root?.querySelector('[data-comments-list]'); + const pane = root?.querySelector('[data-comments-pane]'); + if (!root || !list || !pane) return; + const measure = () => { + const r = root.getBoundingClientRect(); + const l = list.getBoundingClientRect(); + const p = pane.getBoundingClientRect(); + // The host's floating buttons are positioned inside root's + // offsetParent (the doc column, which also holds the metadata strip + // below this view), so the bottom is measured from THAT box. + const host = (root.offsetParent as HTMLElement | null)?.getBoundingClientRect() ?? r; + const next = { + actionsRight: Math.round(r.right - (l.right - 8)), + actionsWidth: Math.round(l.width - 16), + paneLeft: Math.round(r.right - p.left), + actionsBottom: Math.round(host.bottom - p.bottom + 8), + }; + setPaneGeom((cur) => (cur.actionsRight === next.actionsRight && cur.actionsWidth === next.actionsWidth + && cur.paneLeft === next.paneLeft && cur.actionsBottom === next.actionsBottom ? cur : next)); + }; + measure(); + ro = new ResizeObserver(measure); + ro.observe(root); + ro.observe(list); + }); + return () => { cancelAnimationFrame(raf); ro?.disconnect(); }; + }, [commentsMode, artifact.id, showCodeRail, narrowPane]); + + // Round 4 (Destin): the Comments button lives in the host's header icon + // row "alongside the other actions" — same imperative-handle + state + // callback pattern the header already uses for Edit/Save. + useEffect(() => { + // paneVisible: MarkdownView collapses its margin to a marker rail below + // the same 640px pane width narrowPane measures here; the code rail is + // always full width. + const paneVisible = commentsMode === 'comments' && showComments && (showCodeRail || !narrowPane); + onCommentsStateChange?.({ available: showComments, active: commentsMode === 'comments', count: openCount, paneVisible, ...paneGeom }); + }, [showComments, showCodeRail, narrowPane, commentsMode, openCount, paneGeom, onCommentsStateChange]); + // The comment panel's own × (review deck R-3). + const closeCommentsPane = useCallback(() => setCommentsMode('reading'), []); + const openComments = useCallback((commentId?: string) => { + if (commentId) { + if (pathComments.find((c) => c.id === commentId)?.resolved) setPathShowResolved(true); + // An explicit click is always honoured once; only REMOUNTS are ignored + // (CommentsMargin's HANDLED_THREADS). + requestThreadAgain(artifact.path); + setFocusThreadId(commentId); + } + setCommentsMode('comments'); + }, [pathComments, setPathShowResolved, artifact.path]); + + // CHANGE (Destin, testing the dev instance): code files used to force- + // switch into the WHOLE Comments panel the instant a fresh draft appeared + // (this used to be a `useEffect` calling `openComments(pathFocusId)` here, + // added because CodeEditorView never wired up commentsMode/onOpenComments + // at all — types.ts's own WHY: "CodeEditorView's simpler treatment ... + // lives entirely in ActiveArtifactView instead" — and build-menu.ts's "Add + // comment" writes straight into the store with no callback of its own, so + // nothing flipped the mode without this). Code now matches markdown/text: + // Reading mode renders `` below (the same small + // floating box ReadingHighlights shows, anchored through CM6's own + // `coordsAtPos` instead of a DOM Range — CM6 virtualizes, so there's no + // to measure), and the mode never has to change just to type a note. + + // Round 3 (item 6): Comments mode needs the margin's card width; the + // drawer's DEFAULT ~480px pane (SessionDrawer's --right-pane-width) has + // none, which is why it used to fall back to a bottom sheet that covered + // the composer. Fix: entering Comments mode at that width flips the + // drawer's existing Expand control — the same one the header's ⛶ button + // drives — and flipping it back on exit restores exactly what the user + // had. Reusing that shared flag (over inventing a second "wide" concept) + // does mean it's global, not per-file: if the app window itself is under + // 640px wide while Project View's file tab (already full-width in + // practice, so narrowPane there is normally false) has Comments mode + // open, exiting will also un-expand whatever chat session is behind it — + // an accepted, rare edge case flagged in the round-3 report rather than + // solved with a second, viewer-local width flag. + const dispatch = useArtifactDispatchOptional(); + const drawerExpanded = useArtifactSelectorOptional((s) => s.drawerExpanded); + const autoExpandedRef = useRef(false); + // Round 3 coordinator review (defect 1): `narrowPane` starts `false` and + // only becomes `true` once useNarrowByRef's ResizeObserver fires — one or + // more frames after commentsMode flips to 'comments'. A deps array of just + // `[commentsMode]` ran this ONCE, at the instant of the click, and missed + // narrowPane's real value entirely (still measuring), so the auto-expand + // silently never fired and the margin stayed at the default cramped + // width — reproduced with the artifact list open AND closed. Depending on + // narrowPane/drawerExpanded too makes the effect re-check as they settle; + // `autoExpandedRef` still guards it to firing (and un-firing) exactly once. + useEffect(() => { + if (!dispatch) return; // no provider (e.g. a unit test rendering this in isolation) — nothing to reuse + if (commentsMode === 'comments') { + if (narrowPane && !drawerExpanded && !autoExpandedRef.current) { + dispatch({ type: 'DRAWER_EXPAND_TOGGLED' }); + autoExpandedRef.current = true; + } + } else if (autoExpandedRef.current) { + dispatch({ type: 'DRAWER_EXPAND_TOGGLED' }); + autoExpandedRef.current = false; + } + }, [commentsMode, narrowPane, drawerExpanded, dispatch]); + // Restore on unmount too (closing the file entirely while still expanded + // for it) — otherwise the flag leaks past this component's own lifetime. + useEffect(() => () => { + if (autoExpandedRef.current) dispatch?.({ type: 'DRAWER_EXPAND_TOGGLED' }); + }, [dispatch]); + const showPartialBanner = !editing && contentInfo?.truncated === true && typeof contentInfo.sizeBytes === 'number' @@ -576,6 +776,8 @@ export const ActiveArtifactView = forwardRef +
{/* Conflict banner — shown when the file changes on disk while the user has UNSAVED edits. Three actions: keep draft, accept the disk version, @@ -636,32 +838,51 @@ export const ActiveArtifactView = forwardRef
)} -
- {/* Boundary catches lazy chunk-load failures + viewer render crashes - (Suspense alone can't — lazy() THROWS its rejection). Keyed by - artifact so switching files retries with a clean slate. */} - - Loading viewer…
}> - {/* Photo-only build: inside Suspense, so `shoot` marks the file only once its viewer loaded. */} - - - - +
+
+ {/* Boundary catches lazy chunk-load failures + viewer render crashes + (Suspense alone can't — lazy() THROWS its rejection). Keyed by + artifact so switching files retries with a clean slate. */} + + Loading viewer…
}> + {/* Photo-only build: inside Suspense, so `shoot` marks the file only once its viewer loaded. */} + + + + +
+ {/* Code files: the same Comments panel, linked to LINES rather than + in-text highlights (CM6 virtualises its DOM — CodeCommentsRail's + own comment has the WHY). Comments mode only; Reading mode for code + is the plain editor, full width, same as markdown. */} + {showCodeRail && commentsMode === 'comments' && ( + + )} + {/* CHANGE: Reading mode's own small floating box (see the import's + WHY) — the code-file equivalent of ReadingHighlights' popover, + replacing the old force-switch-to-panel effect. */} + {showCodeRail && commentsMode === 'reading' && ( + + )}
{/* Partial-view notice — floats over the BOTTOM of the doc pane, in the spot the Edit pill would occupy (a file this large is read-only, so @@ -675,5 +896,7 @@ export const ActiveArtifactView = forwardRef )} + + ); }); diff --git a/desktop/src/renderer/components/artifact-views/CodeEditorView.tsx b/desktop/src/renderer/components/artifact-views/CodeEditorView.tsx index f1b348e32..6d073a2d0 100644 --- a/desktop/src/renderer/components/artifact-views/CodeEditorView.tsx +++ b/desktop/src/renderer/components/artifact-views/CodeEditorView.tsx @@ -26,6 +26,7 @@ import { languages } from '@codemirror/language-data'; import { useTheme } from '../../state/theme-context'; import { buildCmThemeExtensions } from './cm/cm-theme'; import { registerEditorView, unregisterEditorView } from './cm/editor-registry'; +import { refLineHighlight, installCodeRefHighlight, flashPendingJump } from './cm/ref-line-highlight'; import type { ArtifactViewProps } from './types'; // Bottom scroll margin = the soft-keyboard overlay height. Read at call time @@ -37,7 +38,7 @@ function vvpOffsetPx(): number { return Number.isFinite(n) && n > 0 ? n : 0; } -export function CodeEditorView({ path, content, editing = false, draft = '', onDraftChange }: ArtifactViewProps) { +export function CodeEditorView({ path, content, editing = false, draft = '', onDraftChange, projectRoot }: ArtifactViewProps) { const { activeTheme } = useTheme(); const hostRef = useRef(null); const viewRef = useRef(null); @@ -70,6 +71,8 @@ export function CodeEditorView({ path, content, editing = false, draft = '', onD comp.lang.of(langRef.current ?? []), comp.theme.of(buildCmThemeExtensions()), EditorView.scrollMargins.of(() => ({ bottom: vvpOffsetPx() })), + // Ask-about chips from this file light up their lines (ref-line-highlight.ts). + refLineHighlight, EditorView.updateListener.of((u) => { if (u.docChanged && editingRef.current) onDraftChangeRef.current?.(u.state.doc.toString()); }), @@ -86,6 +89,8 @@ export function CodeEditorView({ path, content, editing = false, draft = '', onD const view = new EditorView({ state: buildStateRef.current(''), parent: host }); viewRef.current = view; registerEditorView(host, view); + installCodeRefHighlight(); + const cancelPending = flashPendingJump(view, path); let dead = false; const base = path.split('/').pop() ?? path; const desc = LanguageDescription.matchFilename(languages, base); @@ -98,6 +103,7 @@ export function CodeEditorView({ path, content, editing = false, draft = '', onD } return () => { dead = true; + cancelPending(); unregisterEditorView(host); view.destroy(); viewRef.current = null; @@ -155,6 +161,15 @@ export function CodeEditorView({ path, content, editing = false, draft = '', onD // path (registry lookup), NEVER the
 textContent path.
         data-artifact-viewer
         data-doc-path={path}
+        // WHY (Destin, dev instance 2026-09-28: "Add comment" on a code file
+        // did nothing): build-menu.ts files a new comment under the project
+        // it reads from THIS attribute, while ActiveArtifactView's Comments
+        // rail and its fresh-draft watcher read the store under the viewer's
+        // own `projectRoot` prop. Text documents stamp it via
+        // CommentableDocument; the code editor never did, so every code
+        // comment landed in the "no project" partition — a draft nobody
+        // looking at this file could see.
+        data-project-root={projectRoot ?? ''}
         data-artifact-source="cm6"
       />
     
diff --git a/desktop/src/renderer/components/artifact-views/DocxView.tsx b/desktop/src/renderer/components/artifact-views/DocxView.tsx
index f9a848c1c..b6d75d113 100644
--- a/desktop/src/renderer/components/artifact-views/DocxView.tsx
+++ b/desktop/src/renderer/components/artifact-views/DocxView.tsx
@@ -2,21 +2,36 @@ import { useEffect, useState } from 'react';
 import type { ArtifactViewProps } from './types';
 import { BinaryContent, CenterNote } from './BinaryContent';
 import { sanitizeDocHtml } from './sanitize-doc-html';
+// Doc comments (Destin, questions deck Q-4: Word comments "work fully
+// natively"): a Word document gets the same highlights, hover card and
+// comment pane as a markdown file — CommentableDocument is that shared layout.
+import { CommentableDocument } from '../comments/CommentableDocument';
 
 // @ts-ignore mammoth.browser lacks type declarations
 import mammoth from 'mammoth/mammoth.browser';
 
-export function DocxView({ absolutePath }: ArtifactViewProps) {
+export function DocxView({ absolutePath, path, commentsMode, onOpenComments, focusThreadId, projectRoot }: ArtifactViewProps) {
   // BinaryContent owns loading/error for the byte read and remounts the inner
   // component per file, so html/parseError can't go stale across switches.
   return (
     
-      {(bytes) => }
+      {(bytes) => (
+        
+      )}
     
   );
 }
 
-function DocxContent({ bytes }: { bytes: Uint8Array }) {
+type CommentProps = Pick;
+
+function DocxContent({ bytes, path, commentsMode, onOpenComments, focusThreadId, projectRoot }: { bytes: Uint8Array } & CommentProps) {
   const [html, setHtml] = useState(null);
   const [parseError, setParseError] = useState(null);
 
@@ -40,9 +55,16 @@ function DocxContent({ bytes }: { bytes: Uint8Array }) {
     // the Tailwind typography plugin isn't installed, and `dark:` follows the OS
     // scheme rather than the active YouCoded theme — so docx bodies rendered as
     // flat unstyled text under the preflight reset.
-    
+ +
+ ); } diff --git a/desktop/src/renderer/components/artifact-views/MarkdownView.tsx b/desktop/src/renderer/components/artifact-views/MarkdownView.tsx index b056d75e4..ae8994020 100644 --- a/desktop/src/renderer/components/artifact-views/MarkdownView.tsx +++ b/desktop/src/renderer/components/artifact-views/MarkdownView.tsx @@ -3,6 +3,10 @@ // so the conflict banner has access to the in-progress draft. import MarkdownContent from '../MarkdownContent'; import type { ArtifactViewProps } from './types'; +// Doc comments: the highlights / comment pane layout is shared with the Word +// and spreadsheet viewers (CommentableDocument's own WHY). Skipped in edit +// mode: a highlighted span over a live textarea draft has nothing to anchor to. +import { CommentableDocument } from '../comments/CommentableDocument'; // NOTE: Edit/Save/Cancel live in the HOST's header (SessionDrawer toolbar / // ProjectDetailOverlay tools via the controlsInHeader handle) — this view never @@ -11,6 +15,7 @@ import type { ArtifactViewProps } from './types'; export function MarkdownView({ path, content, editing = false, draft = '', onDraftChange, + commentsMode = 'reading', onOpenComments, focusThreadId, projectRoot, }: ArtifactViewProps) { if (content === null) { // Loading / missing / read-error are rendered by ActiveArtifactView (which @@ -41,20 +46,28 @@ export function MarkdownView({ const isMarkdown = path.endsWith('.md') || path.endsWith('.markdown'); return ( -
-
- {isMarkdown - ? - :
{content}
} -
-
+ + {/* WHY key={content} (2026-09-28 PR review): comment highlights split + and wrap the text nodes React rendered here. When the file changed on + disk (the assistant editing it), React patched those same nodes in + place — doubling highlighted words, or throwing "The node to be + removed is not a child of this node" and replacing the viewer with an + error. Keying on the text makes React build the document fresh + instead of patching nodes the highlights moved; the highlights then + re-apply to the new text. Nothing changes while the text is the same. */} + {isMarkdown + ? + :
{content}
} +
); } diff --git a/desktop/src/renderer/components/artifact-views/RendererRegistry.ts b/desktop/src/renderer/components/artifact-views/RendererRegistry.ts index ee6c69b42..e1f425893 100644 --- a/desktop/src/renderer/components/artifact-views/RendererRegistry.ts +++ b/desktop/src/renderer/components/artifact-views/RendererRegistry.ts @@ -107,6 +107,22 @@ export function isTextContentViewer(v: unknown): boolean { return TEXT_CONTENT_VIEWERS.has(v as ViewSpec); } +// WHY its own check, not exporting CodeEditorView itself: doc comments (mockup, +// Style A) give code files a simpler, non-scroll-synced comments rail than +// MarkdownView's inline margin — ActiveArtifactView needs to know "is this the +// CM6 viewer" without importing the lazy chunk directly. +export function isCodeEditorViewer(v: unknown): boolean { + return v === CodeEditorView; +} + +// Doc comments: the two binary viewers that ALSO take comments — Word (text +// highlights) and Excel (cell comments), per Destin's questions deck Q-4 and +// the Excel follow-up. Every other binary preview (image, pdf, csv grid) has +// nothing a comment could anchor to yet. +export function isCommentableBinaryViewer(v: unknown): boolean { + return v === DocxView || v === XlsxView; +} + export function getViewer(path: string, opts?: { textHint?: boolean; binaryHint?: boolean }): ViewSpec { const ext = path.split('.').pop()?.toLowerCase() ?? ''; const hit = REGISTRY[ext]; diff --git a/desktop/src/renderer/components/artifact-views/XlsxView.tsx b/desktop/src/renderer/components/artifact-views/XlsxView.tsx index 4f2a8a352..4bddeea25 100644 --- a/desktop/src/renderer/components/artifact-views/XlsxView.tsx +++ b/desktop/src/renderer/components/artifact-views/XlsxView.tsx @@ -7,6 +7,11 @@ import { } from './exceljs-cell'; import { evalFormula, type CellValue } from './xlsx-formula'; // Theme-tinted "paper" constants shared with CsvView — see sheet-theme.ts. +// Doc comments (Destin, chat follow-up: Excel comments same as Word): cell +// comments share the markdown/Word comment layout; each carries its +// address in data-cell so the comment's cell can be found and marked. +import { onSheetReveal } from '../comments/sheet-reveal'; +import { CommentableDocument } from '../comments/CommentableDocument'; import { PAPER, GUTTER_BG, FBAR_BG, TAB_BG, GRID, GUTTER_FG, SEL, NOTE_FG, NOTE_BG, largeSheetNote } from './sheet-theme'; // Safety caps — agent sheets are small, but guard against a pathological file @@ -133,17 +138,29 @@ function buildSheet(ws: any): SheetVM { return { name: ws.name || 'Sheet', colCount, colWidths, rows, byKey, truncated, rowsTruncated, colsTruncated }; } -export function XlsxView({ absolutePath }: ArtifactViewProps) { +export function XlsxView({ absolutePath, path, commentsMode, onOpenComments, focusThreadId, projectRoot }: ArtifactViewProps) { // BinaryContent owns loading/error for the byte read and remounts the inner // component per file, so sheets/selection/parse errors reset on switch. return ( - {(bytes) => } + {(bytes) => ( + + + + )} ); } -function XlsxSheets({ bytes }: { bytes: Uint8Array }) { +function XlsxSheets({ bytes, path }: { bytes: Uint8Array; path: string }) { const [sheets, setSheets] = useState(null); const [active, setActive] = useState(0); const [sel, setSel] = useState<{ r: number; c: number } | null>(null); @@ -172,6 +189,13 @@ function XlsxSheets({ bytes }: { bytes: Uint8Array }) { return () => { cancelled = true; }; }, [bytes]); + // A comment on another tab (its card clicked, or its chip) asks for that + // tab to be shown so its cell can be found — comments/sheet-reveal.ts. + useEffect(() => onSheetReveal(path, (name) => { + const i = sheets?.findIndex((s) => s.name === name) ?? -1; + if (i >= 0) { setActive(i); setSel(null); } + }), [path, sheets]); + const sheet = sheets?.[active]; // Formula bar contents for the selected cell: its formula if any, else value. const selInfo = useMemo(() => { @@ -200,8 +224,11 @@ function XlsxSheets({ bytes }: { bytes: Uint8Array }) {
- {/* Grid */} -
+ {/* Grid. data-sheet / data-sheet-count: which tab these cells belong to, + so a cell comment on another tab never lands on this tab's C4 + (use-quote-marks.ts cellSelector), and the right-click menu can name + the tab when there is more than one (build-menu.ts cellEntries). */} +
@@ -237,7 +264,11 @@ function XlsxSheets({ bytes }: { bytes: Uint8Array }) { colSpan={cell.colSpan > 1 ? cell.colSpan : undefined} rowSpan={cell.rowSpan > 1 ? cell.rowSpan : undefined} style={style} + data-cell={`${colLetter(cell.c)}${cell.r}`} onClick={() => setSel({ r: cell.r, c: cell.c })} + // Right-click selects the cell too, so the formula bar + // shows which cell "Add comment" is about to attach to. + onContextMenu={() => setSel({ r: cell.r, c: cell.c })} > {cell.display} diff --git a/desktop/src/renderer/components/artifact-views/cm/ref-line-highlight.ts b/desktop/src/renderer/components/artifact-views/cm/ref-line-highlight.ts new file mode 100644 index 000000000..b9b33415b --- /dev/null +++ b/desktop/src/renderer/components/artifact-views/cm/ref-line-highlight.ts @@ -0,0 +1,136 @@ +// Code-file half of "a chip points back at its source" (Destin, 2026-09-24: +// "click the chip and have it focus/highlight the originating text… hover +// sensitive as well"). A chip from a code file carries a line range, and +// CodeMirror virtualises its DOM (only the lines near the viewport exist), so +// the CSS-Highlight approach the other viewers use can't reach an off-screen +// line. This paints LINE DECORATIONS through CodeMirror's own state instead — +// they exist whether or not the line is currently drawn — and scrolls with +// CodeMirror's own scrollIntoView. +// +// hover a chip → its lines get a soft accent wash +// click a chip → scrolled to (centred) and a stronger wash for FLASH_MS +// click while the file is closed → compose-ref.ts opens it; CodeEditorView +// then calls flashPendingJump once the text has loaded +import { StateEffect, StateField, RangeSetBuilder, type EditorState, type Extension } from '@codemirror/state'; +import { Decoration, EditorView, type DecorationSet } from '@codemirror/view'; +import { editorViewFor } from './editor-registry'; +import { takePendingJump, type ComposeRef } from '../../context-menu/compose-ref'; + +type Lines = [number, number] | null; +interface Lit { hover: Lines; flash: Lines } + +const setLit = StateEffect.define>(); + +const litField = StateField.define({ + create: () => ({ hover: null, flash: null }), + update(value, tr) { + for (const e of tr.effects) if (e.is(setLit)) value = { ...value, ...e.value }; + return value; + }, +}); + +const HOVER_LINE = Decoration.line({ class: 'cm-ref-source-hover' }); +const FLASH_LINE = Decoration.line({ class: 'cm-ref-source-flash' }); + +function decorate(state: EditorState): DecorationSet { + const { hover, flash } = state.field(litField); + const n = state.doc.lines; + // Line number → decoration; flash wins where the two overlap. Ranges are a + // chip's few lines, so this never walks the whole file. + const lit = new Map(); + if (hover) for (let l = Math.max(1, hover[0]); l <= Math.min(n, hover[1]); l++) lit.set(l, HOVER_LINE); + if (flash) for (let l = Math.max(1, flash[0]); l <= Math.min(n, flash[1]); l++) lit.set(l, FLASH_LINE); + const builder = new RangeSetBuilder(); + for (const l of [...lit.keys()].sort((x, y) => x - y)) { + const from = state.doc.line(l).from; + builder.add(from, from, lit.get(l)!); + } + return builder.finish(); +} + +/** Add to the editor's extensions (CodeEditorView.buildState). */ +export const refLineHighlight: Extension = [ + litField, + EditorView.decorations.compute([litField], decorate), + // Same washes as the other viewers' ::highlight(ref-source-*) rules. + EditorView.baseTheme({ + '.cm-ref-source-hover': { backgroundColor: 'color-mix(in srgb, var(--accent) 22%, transparent)' }, + '.cm-ref-source-flash': { backgroundColor: 'color-mix(in srgb, var(--accent) 38%, transparent)' }, + }), +]; + +const FLASH_MS = 1800; +let flashTimer: number | null = null; +let hoveredView: EditorView | null = null; + +function clampLines(view: EditorView, r: [number, number]): [number, number] { + const n = view.state.doc.lines; + return [Math.max(1, Math.min(r[0], n)), Math.max(1, Math.min(r[1], n))]; +} + +function flash(view: EditorView, range: [number, number]): void { + const lines = clampLines(view, range); + view.dispatch({ + effects: [ + setLit.of({ flash: lines }), + EditorView.scrollIntoView(view.state.doc.line(lines[0]).from, { y: 'center' }), + ], + }); + if (flashTimer) window.clearTimeout(flashTimer); + flashTimer = window.setTimeout(() => { flashTimer = null; view.dispatch({ effects: setLit.of({ flash: null }) }); }, FLASH_MS); +} + +/** The visible code editor showing `path`, if any. Exported for T14's + * `use-code-comment-anchors.ts` — CodeCommentsRail's own anchoring pass + * needs the SAME live-editor lookup this module already does for chip + * hover/click, so a comment's resolved lines and a chip's never disagree + * about which mounted EditorView is "the" one for a path. */ +export function visibleEditorFor(path: string): EditorView | null { + for (const el of document.querySelectorAll('[data-artifact-source="cm6"]')) { + if (el.dataset.docPath === path && el.getClientRects().length > 0) return editorViewFor(el); + } + return null; +} + +let installed = false; +/** Idempotent — CodeEditorView calls it on mount. One listener pair for the + * whole app, so hidden editors hold none (performance.md rule 2). */ +export function installCodeRefHighlight(): void { + if (installed || typeof window === 'undefined') return; + installed = true; + window.addEventListener('youcoded:ref-hover', (e) => { + const ref = (e as CustomEvent<{ ref: ComposeRef | null }>).detail?.ref; + if (hoveredView) { hoveredView.dispatch({ effects: setLit.of({ hover: null }) }); hoveredView = null; } + if (!ref || ref.kind !== 'doc' || !ref.lineRange || !ref.path) return; + const view = visibleEditorFor(ref.path); + if (!view) return; + view.dispatch({ effects: setLit.of({ hover: clampLines(view, ref.lineRange) }) }); + hoveredView = view; + }); + window.addEventListener('youcoded:jump-to-ref', (e) => { + const detail = (e as CustomEvent<{ ref?: ComposeRef; handled?: boolean }>).detail; + const ref = detail?.ref; + if (!ref || ref.kind !== 'doc' || !ref.lineRange || !ref.path) return; + const view = visibleEditorFor(ref.path); + if (!view) return; + detail.handled = true; + flash(view, ref.lineRange); + }); +} + +/** A chip clicked while this file was closed: flash its lines once the file's + * text has loaded (the editor starts empty and is filled asynchronously). */ +export function flashPendingJump(view: EditorView, path: string): () => void { + const ref = takePendingJump(path); + if (!ref?.lineRange) return () => {}; + const range = ref.lineRange; + let tries = 0; + let timer: number | null = null; + const attempt = () => { + timer = null; + if (view.state.doc.lines >= range[0] && view.state.doc.length > 0) { flash(view, range); return; } + if (++tries < 30) timer = window.setTimeout(attempt, 150); + }; + attempt(); + return () => { if (timer) window.clearTimeout(timer); }; +} diff --git a/desktop/src/renderer/components/artifact-views/types.ts b/desktop/src/renderer/components/artifact-views/types.ts index d449b1532..f30e23a49 100644 --- a/desktop/src/renderer/components/artifact-views/types.ts +++ b/desktop/src/renderer/components/artifact-views/types.ts @@ -33,4 +33,24 @@ export interface ArtifactViewProps { * corner (ContentFindBar's default `top-2 right-2`). A viewer with its own * floating control up there moves it down out of the way. */ findBarOpen?: boolean; + // Doc comments (mockup, round 2): Reading mode (highlights + hover cards, + // full width) vs Comments mode (the margin/markers rail). Owned by + // ActiveArtifactView (the toggle lives in its header bar) — MarkdownView is + // the only consumer today; CodeEditorView's simpler treatment (its rail + // shown/hidden by mode) lives entirely in ActiveArtifactView instead. + commentsMode?: 'reading' | 'comments'; + /** "Open in comments" (the hover card's link) — switches mode AND focuses + * the given thread. Owned by ActiveArtifactView for the same reason. */ + onOpenComments?: (commentId?: string) => void; + /** The thread "Open in comments" asked to focus — set once per click, read + * once by CommentsMargin (via MarkdownView) to scroll/highlight it. */ + focusThreadId?: string; + /** T5 (doc-comments build, design §7): the real docComments:* IPC needs a + * project root to find the right `.youcoded/comments/` sidecar (or a + * `.docx`/`.xlsx` target's own bytes) instead of the per-machine + * loose-file fallback. ActiveArtifactView already has this as its own + * prop; threaded through here so MarkdownView/DocxView/XlsxView can hand + * it to `useDocComments`/`CommentableDocument` without each viewer having + * to resolve a project root of its own. */ + projectRoot?: string; } diff --git a/desktop/src/renderer/components/comments/Avatar.tsx b/desktop/src/renderer/components/comments/Avatar.tsx new file mode 100644 index 000000000..7e0ef95d7 --- /dev/null +++ b/desktop/src/renderer/components/comments/Avatar.tsx @@ -0,0 +1,40 @@ +// Shared comment-thread avatar — CommentCard and HighlightHoverCard each had +// their own byte-identical copy, which had drifted (round 3 finding: the +// user's initial was 'D' — Destin's own initial — while the name it sits next +// to reads "You", a mismatch nobody would type on purpose twice). One +// component now owns the mapping so the two can't disagree again. +// +// WHY a letter/glyph, not an accent fill: G-8 reserves the accent colour for +// STATE (selection, focus, primary actions) — a resting avatar is decoration, +// so it stays neutral like every other tag/badge in the app. +import type { CommentAuthor } from '../../state/doc-comments-store'; + +export function authorName(author: CommentAuthor): string { + if (author === 'assistant') return 'Claude'; + if (author === 'user') return 'You'; + // A colleague's comment from a Word/Excel file: their own name, as Word + // shows it (doc-comments-store.ts CommentAuthor's WHY). + return author.slice('person:'.length); +} + +/** Lower-case form for the middle of a sentence ("Resolved by you"). */ +export function authorNameInline(author: CommentAuthor): string { + return author === 'user' ? 'you' : authorName(author); +} + +function initialOf(author: CommentAuthor): string { + if (author === 'assistant') return '✳'; + if (author === 'user') return 'Y'; + return authorName(author).trim().charAt(0).toUpperCase() || '?'; +} + +export function Avatar({ author }: { author: CommentAuthor }) { + return ( + + {initialOf(author)} + + ); +} diff --git a/desktop/src/renderer/components/comments/CodeCommentPopover.tsx b/desktop/src/renderer/components/comments/CodeCommentPopover.tsx new file mode 100644 index 000000000..9ae490fde --- /dev/null +++ b/desktop/src/renderer/components/comments/CodeCommentPopover.tsx @@ -0,0 +1,80 @@ +// CodeCommentPopover — code files' equivalent of ReadingHighlights' own +// "Add comment" flow (Destin, testing the dev instance: right-click on a +// code file → "Add comment" should look and behave exactly like it does on +// a markdown/text file — a small floating box right at the selection, +// without leaving the plain editor. Before this file existed, code files +// took a different path entirely: ActiveArtifactView had an effect that +// force-switched into the WHOLE Comments panel (CodeCommentsRail) the +// instant a fresh draft appeared, because CM6 has no in-text to hang +// a popover off of the way ReadingHighlights does for rendered text +// (use-code-comment-anchors.ts's own WHY has the full "CM6 virtualizes its +// DOM" reasoning). That effect is gone — see ActiveArtifactView's own WHY at +// its old call site — replaced by this component, which anchors the SAME +// NewCommentPopover through CodeMirror's own `coordsAtPos` instead of a DOM +// Range. +// +// Existing line markers/anchoring for already-persisted code comments are +// unchanged (CodeCommentsRail + use-code-comment-anchors.ts, shown only in +// Comments mode) — this component only covers the moment a FRESH draft is +// being composed, while still in Reading mode. +import { useEffect, useState } from 'react'; +import { NewCommentPopover } from './NewCommentPopover'; +import { useDocComments } from '../../state/doc-comments-store'; +import { visibleEditorFor } from '../artifact-views/cm/ref-line-highlight'; + +interface Props { + path: string; + /** T5: forwarded to `useDocComments` for the real docComments:* IPC. */ + projectRoot?: string; +} + +interface Anchor { + rect: DOMRect; + bounds: HTMLElement | null; +} + +export function CodeCommentPopover({ path, projectRoot }: Props) { + const { comments, focusId, setCommentText, removeComment, clearFocus } = useDocComments(path, projectRoot); + const draftComment = comments.find((c) => c.id === focusId) ?? null; + const [anchor, setAnchor] = useState(null); + + // Captured once per fresh draft — mirrors ReadingHighlights' own + // draftAnchor effect (read once when focusId changes, never on every + // keystroke). build-menu.ts's "Add comment" runs off the LIVE CM6 + // selection and never clears it, so that selection is still exactly what + // was right-clicked by the time this effect runs. + useEffect(() => { + if (!focusId) { setAnchor(null); return; } + const view = visibleEditorFor(path); + if (!view) { setAnchor(null); return; } + const comment = comments.find((c) => c.id === focusId); + const range = view.state.selection.main; + // A selection's END, same as ReadingHighlights' lastRectOf — falls back + // to the line the comment was minted against (cellEntries/artifactMenu's + // own `lineOpts`) on the rare chance the live selection already moved on. + const line = comment?.startLine != null + ? Math.max(1, Math.min(comment.startLine, view.state.doc.lines)) + : null; + const pos = !range.empty ? range.to : line != null ? view.state.doc.line(line).to : null; + const coords = pos != null ? view.coordsAtPos(pos) : null; + if (!coords) { setAnchor(null); return; } + // CM6's editor host carries `data-artifact-viewer` (CodeEditorView.tsx) — + // the same "viewer's own content area, never the window" bound + // NewCommentPopover's own WHY requires. + const bounds = view.dom.closest('[data-artifact-viewer]'); + setAnchor({ rect: new DOMRect(coords.right, coords.bottom, 0, 0), bounds }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- read once per fresh draft (focusId); comments/path are looked up, not triggers + }, [focusId]); + + if (!draftComment || !anchor) return null; + return ( + setCommentText(draftComment.id, t)} + onDone={clearFocus} + onCancel={() => removeComment(draftComment.id)} + /> + ); +} diff --git a/desktop/src/renderer/components/comments/CodeCommentsRail.tsx b/desktop/src/renderer/components/comments/CodeCommentsRail.tsx new file mode 100644 index 000000000..29e8f7dca --- /dev/null +++ b/desktop/src/renderer/components/comments/CodeCommentsRail.tsx @@ -0,0 +1,115 @@ +// CodeCommentsRail — Comments mode for code files. Same panel as text +// documents (CommentsPaneFrame: title row, Show Resolved switch, Ask Your +// Assistant floating at the bottom) — polish pass, Destin: code comments +// should get "the same close look as markdown". +// +// What differs, and why: CodeMirror 6 virtualises its DOM (only lines near +// the viewport exist), so there are no in-text highlights to link a +// card to. Instead a card links to its LINES through CodeMirror's own line +// decorations (ref-line-highlight.ts): hovering a card washes its lines, +// clicking one scrolls there and flashes them. +import { useMemo, useState } from 'react'; +import { CommentCard } from './CommentCard'; +import { CommentsPaneFrame } from './CommentsPaneFrame'; +import { EmptyState } from '../ui/states'; +import { useDocComments, type DocComment } from '../../state/doc-comments-store'; +import { dispatchRefHover, jumpToRef, type ComposeRef } from '../context-menu/compose-ref'; +// T14 (§2.2/§2.3): a card's lines come from the LIVE anchoring pass, not the +// comment's own stale creation-time startLine/endLine — see the hook's WHY. +import { useCodeCommentAnchors, type ResolvedLines } from './use-code-comment-anchors'; + +interface Props { + path: string; + /** T5: forwarded to `useDocComments` for the real docComments:* IPC. */ + projectRoot?: string; +} + +/** A card's CURRENTLY-resolved lines as a ref, so the chip highlighter lights + * the right lines even after an edit moved them. `null` (no jump/hover + * wiring at all) when `resolveSelector` couldn't anchor this comment right + * now — §2.3: a detached comment gets no highlight, never a stale one. */ +function linesRef(c: DocComment, path: string, resolved: Map): ComposeRef | null { + const lines = resolved.get(c.id); + if (!lines) return null; + return { id: c.id, kind: 'doc', label: '', path, lineRange: [lines.startLine, lines.endLine] }; +} + +export function CodeCommentsRail({ path, projectRoot }: Props) { + const { + comments, focusId, showResolved, setCommentText, commitDraft, addReply, resolveComment, reopenComment, removeComment, + editComment, editReply, deleteComment, deleteReply, + } = useDocComments(path, projectRoot); + const [selectedId, setSelectedId] = useState(null); + // T14 fix: this MUST be memoized, not recomputed on every render — it now + // feeds `useCodeCommentAnchors`'s effect deps below, and an unmemoized + // `.filter().sort()` hands that effect a BRAND NEW array reference on + // every render (including one that hook's OWN `setResolved` just caused), + // retriggering it forever (`Error: Maximum update depth exceeded`, found + // running this in the workbench). CommentsMargin.tsx/ReadingHighlights.tsx + // already memoize their own equivalent `visible` for the same reason. + const visible = useMemo( + () => comments.filter((c) => showResolved || !c.resolved).sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0) || a.createdAt - b.createdAt), + [comments, showResolved], + ); + const resolvedLines = useCodeCommentAnchors(path, visible); + // T6 (design §2.3): `visible` above is sorted by each comment's STALE + // creation-time `startLine` — fine as `useCodeCommentAnchors`'s input (order + // doesn't affect what it resolves), but wrong for what the user actually + // sees: a comment `resolveSelector` can no longer anchor has no live line to + // sort by at all, so it needs to drop out of "document order" rather than + // sit wherever its old line number happens to land it. Anchored comments + // are re-sorted by their CURRENT line (an edit above them may have shifted + // it since `visible` was sorted); detached ones are grouped after, in the + // order they were already in. + const ordered = useMemo(() => { + const anchored = visible.filter((c) => resolvedLines.has(c.id)); + anchored.sort((a, b) => resolvedLines.get(a.id)!.startLine - resolvedLines.get(b.id)!.startLine); + const detached = visible.filter((c) => !resolvedLines.has(c.id)); + return [...anchored, ...detached]; + }, [visible, resolvedLines]); + + return ( + + {/* pb-28: room to scroll the last card up past the floating Ask Your + Assistant button. */} +
+ {ordered.length === 0 && ( + + )} + {ordered.map((c) => { + const ref = linesRef(c, path, resolvedLines); + return ( +
{ + if (!ref || (e.target as HTMLElement).closest('button, input, textarea')) return; + setSelectedId(c.id); + jumpToRef(ref); + }} + onMouseEnter={ref ? () => dispatchRefHover(ref) : undefined} + onMouseLeave={ref ? () => dispatchRefHover(null) : undefined} + className={`rounded-lg transition-shadow ${ref ? 'cursor-pointer' : ''} ${c.id === selectedId ? 'ring-2 ring-accent/60' : ''}`} + > + setCommentText(c.id, t)} + onCommit={() => commitDraft(c.id)} + onReply={(t) => addReply(c.id, 'user', t)} + onResolve={() => resolveComment(c.id, 'user')} + onReopen={() => reopenComment(c.id)} + onDelete={() => removeComment(c.id)} + onEditText={(t) => editComment(c.id, t)} + onDeleteComment={() => deleteComment(c.id)} + onEditReply={(replyId, t) => editReply(c.id, replyId, t)} + onDeleteReply={(replyId) => deleteReply(c.id, replyId)} + /> +
+ ); + })} +
+
+ ); +} diff --git a/desktop/src/renderer/components/comments/CommentActions.tsx b/desktop/src/renderer/components/comments/CommentActions.tsx new file mode 100644 index 000000000..3fd118459 --- /dev/null +++ b/desktop/src/renderer/components/comments/CommentActions.tsx @@ -0,0 +1,284 @@ +// Shared edit/delete affordances for a comment or reply — CommentCard (both +// its open and resolved branches, plus each reply row) and HighlightHoverCard +// (Reading mode's hover preview, plus its reply rows) all need the identical +// icon pair, inline editor and delete confirm. One file so the two surfaces +// can't drift the way Avatar.tsx's own WHY describes two earlier byte- +// identical copies doing. +// +// Design (Destin's decisions, docs/active/design/2026-09-24-doc-comments/ +// doc-comments.edit-delete.questions.answers.json): +// E-1: small Edit/Delete ICONS next to the Resolve toggle, not a ⋯ menu — +// "this is how most other editors do it". Hover-revealed on desktop +// (matches the app's other row-level icon affordances — ProjectSwitcher's +// remove-project ×), always visible+tappable on touch via the app's own +// `.touch-reveal`/`.coarse-hit` conventions (narrow-viewport.md: "hover- +// only affordances have no touch path"). +// E-2: anyone's comment/reply can be edited or deleted — no author check. +// E-3: deleting a thread's first comment deletes the whole thread. True for +// free in this data model (a thread IS one DocComment plus its nested +// `replies`), so the delete-confirm copy just SAYS so. +// E-4: delete asks first, inline — "Delete this comment and its 3 replies?" +// with Delete/Cancel. No dedicated confirm-popover primitive exists in +// `components/ui/` today, and this is small enough not to need one: it +// reuses the same ` + + + ); +} + +/** Destin, 2026-09-28 review deck (D-2): "undo my dark editing pill idea. + * keep editing and the icon, but leave as bare text without pill/container." + * So: quiet muted text + pencil, no fill, no border — the same look as the + * name line's other muted text. Still a button (clicking it cancels the edit), + * with the row's steady cursor. */ +const EDITING_PILL = + 'inline-flex items-center gap-1 shrink-0 text-2xs text-fg-muted hover:text-fg-2 cursor-pointer coarse-hit transition-colors ' + + FOCUS_RING; + +/** Ask 3 (Destin, 2026-09-28, restyle round): while a row is being edited, + * its Edit pencil (and, for CommentRowActions below, Delete/Resolve too) + * sit exactly where they'd otherwise be, replaced by this one quiet "Editing ✎" label. Clicking it cancels the + * edit — same effect as InlineEditField's Cancel button, just reachable + * from the exact spot the eye is already on. + * + * Not exported (knip): only CommentRowActions below renders it. */ +function EditingPill({ onCancel }: { onCancel: () => void }) { + return ( + + ); +} + +/** Ask 2+3, shared by CommentCard and HighlightHoverCard so the swap between + * "Edit/Delete icons (+ an optional trailing node, e.g. Resolve)" and "the + * Editing pill alone" is written once. While `editing` is true this row's + * own Delete AND `trailing` (a top-level comment's Resolve toggle) are both + * gone, not just Edit — ask 2's "hide that comment's delete + resolve + * buttons" / "hide that reply's own delete button" falls out of returning + * ONLY the pill, with nothing else in this row to hide separately. */ +export function CommentRowActions({ editing, onEdit, onDelete, onCancelEdit, editLabel, deleteLabel, trailing }: { + editing: boolean; + onEdit: () => void; + onDelete: () => void; + onCancelEdit: () => void; + editLabel: string; + deleteLabel: string; + trailing?: React.ReactNode; +}) { + if (editing) return ; + return ( +
+ + {trailing} +
+ ); +} + +/** WHY (Destin, 2026-09-28: the edit box cut text off after two lines): the + * comment boxes grow with what's typed, up to a cap, then scroll. CSS + * `field-sizing: content` does it with no script measuring the box on every + * keystroke (performance.md rule 6 — the InputBar's scrollHeight approach + * reads layout per key); Electron's Chromium supports it. */ +const GROWING_FIELD_STYLE = { fieldSizing: 'content', minHeight: '3.2em', maxHeight: '14em', overflowY: 'auto' } as React.CSSProperties; + +/** Inline replacement for a comment/reply's text while editing it. Mounted + * only while the caller's own edit-mode flag is true, and that flag NEVER + * flips from something typed inside this field — only from the Edit button + * that opened it and Save/Cancel/Escape here — so it can never unmount + * mid-keystroke the way the recent draft-focus bug did (doc-comments- + * store.ts's `commitDraft`/CommentCard's `editingDraft`, both keyed off a + * flag a keystroke could flip). A local `value` buffer, not the live store + * text: nothing is saved until Save/Enter. + * + * Restyle (Destin, 2026-09-28, re-reviewing the dev instance): + * - The surface/padding/text-size/focus-border here already match + * CommentComposer's (both go through FIELD_TEXT + FIELD_SIZE.sm on an + * FIELD_SURFACE-equivalent wrapper) — what didn't match was Save reading + * as a light secondary button next to the composer's dark filled send + * arrow. Save is now `variant="primary"`, the SAME filled look as that + * arrow, through the Button primitive rather than a hand-rolled class. + * Cancel stays `ghost` — quiet, as asked. + * - Clearing the box to nothing swaps Save for a `danger` Delete button + * (E-4's existing inline confirm is what it opens — `onRequestDelete`, + * not a second delete path) — Enter while empty does the same. Typing + * text back swaps it right back to Save; both branches read off the same + * `isEmpty`, so there's no separate state to fall out of sync. */ +export function InlineEditField({ text, onSave, onCancel, onRequestDelete, className = 'mt-1' }: { + text: string; + onSave: (text: string) => void; + onCancel: () => void; + /** Empty-box Delete (via the button or Enter) — same delete confirm the + * standalone Delete icon opens, never a second/silent deletion path. */ + onRequestDelete: () => void; + className?: string; +}) { + const [value, setValue] = useState(text); + const ref = useRef(null); + useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []); + const isEmpty = value.trim().length === 0; + const save = () => { if (!isEmpty) onSave(value.trim()); }; + const submit = () => { if (isEmpty) onRequestDelete(); else save(); }; + return ( + // Destin, 2026-09-28: Cancel/Save move INSIDE the field's own border, + // bottom-right — one bordered box (textarea on top, buttons at the + // bottom) instead of a border on the textarea and a separate row below + // it. Same structure InputGroup.tsx's own WHY describes ("the border + // moves to a wrapper and the field goes bare"), just stacked instead of + // inline — this row is TWO buttons (Cancel + Save), which is why it's + // built here rather than through InputGroup itself: that primitive's + // own §11.9 sub-rule limits it to a single submit action inside, and + // this box deliberately carries both, on Destin's explicit ask. +
+