Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: migrate entire codebase to TypeScript (NO any)

Complete TypeScript migration of all source and test files:

Server (moved to server/):
- server.js → server/index.ts with full Express/WS/SQLite types
- DocumentRow, VersionRow, WsControlMessage interfaces
- tsx for development and production execution

Core lib (src/lib/ — 8 modules):
- crypto.ts, provider.ts, version-history.ts, offline.ts
- share-dialog.ts, suggesting.ts, print-layout.ts, context-menu.ts
- Full interfaces for all data structures and event types

Sheets (src/sheets/ — 25 modules):
- types.ts shared interface file (CellStyle, CellData, CellRef, Token, etc.)
- formulas.ts with RangeArray, typed parser/evaluator
- recalc.ts with CellStore interface
- All feature modules fully typed (charts, filter, sort, CF, validation, etc.)

Docs (src/docs/ — 27 modules):
- types.ts + html2pdf.d.ts + vendor.d.ts for third-party libs
- TipTap extensions with proper generics
- All feature modules typed (search, markdown, outline, etc.)

Landing (3 modules):
- landing.ts, landing-utils.ts, landing-dragdrop.ts with DocumentMeta types

Tests (69 files):
- All .test.js → .test.ts with typed mocks and imports

Infrastructure:
- tsconfig.json (strict, noImplicitAny, noUncheckedIndexedAccess)
- vite.config.ts, package.json updated for tsx
- Zero any types across entire codebase

1800 tests passing, zero regressions.

+4475 -2754
+1
.gitignore
··· 4 4 *.db-wal 5 5 *.db-shm 6 6 .env 7 + .claude/worktrees/
+684
package-lock.json
··· 47 47 "yjs": "^13.6.20" 48 48 }, 49 49 "devDependencies": { 50 + "@types/better-sqlite3": "^7.6.13", 51 + "@types/compression": "^1.8.1", 52 + "@types/express": "^5.0.6", 53 + "@types/node": "^25.5.0", 54 + "@types/ws": "^8.18.1", 50 55 "concurrently": "^9.1.0", 51 56 "jsdom": "^29.0.0", 52 57 "jszip": "^3.10.1", 58 + "tsx": "^4.21.0", 59 + "typescript": "^5.9.3", 53 60 "vite": "^6.0.0", 54 61 "vitest": "^4.1.0" 55 62 } ··· 1692 1699 "@tiptap/pm": "^2.7.0" 1693 1700 } 1694 1701 }, 1702 + "node_modules/@types/better-sqlite3": { 1703 + "version": "7.6.13", 1704 + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", 1705 + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", 1706 + "dev": true, 1707 + "license": "MIT", 1708 + "dependencies": { 1709 + "@types/node": "*" 1710 + } 1711 + }, 1712 + "node_modules/@types/body-parser": { 1713 + "version": "1.19.6", 1714 + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", 1715 + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", 1716 + "dev": true, 1717 + "license": "MIT", 1718 + "dependencies": { 1719 + "@types/connect": "*", 1720 + "@types/node": "*" 1721 + } 1722 + }, 1695 1723 "node_modules/@types/chai": { 1696 1724 "version": "5.2.3", 1697 1725 "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", ··· 1703 1731 "assertion-error": "^2.0.1" 1704 1732 } 1705 1733 }, 1734 + "node_modules/@types/compression": { 1735 + "version": "1.8.1", 1736 + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", 1737 + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", 1738 + "dev": true, 1739 + "license": "MIT", 1740 + "dependencies": { 1741 + "@types/express": "*", 1742 + "@types/node": "*" 1743 + } 1744 + }, 1745 + "node_modules/@types/connect": { 1746 + "version": "3.4.38", 1747 + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", 1748 + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", 1749 + "dev": true, 1750 + "license": "MIT", 1751 + "dependencies": { 1752 + "@types/node": "*" 1753 + } 1754 + }, 1706 1755 "node_modules/@types/deep-eql": { 1707 1756 "version": "4.0.2", 1708 1757 "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", ··· 1717 1766 "dev": true, 1718 1767 "license": "MIT" 1719 1768 }, 1769 + "node_modules/@types/express": { 1770 + "version": "5.0.6", 1771 + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", 1772 + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", 1773 + "dev": true, 1774 + "license": "MIT", 1775 + "dependencies": { 1776 + "@types/body-parser": "*", 1777 + "@types/express-serve-static-core": "^5.0.0", 1778 + "@types/serve-static": "^2" 1779 + } 1780 + }, 1781 + "node_modules/@types/express-serve-static-core": { 1782 + "version": "5.1.1", 1783 + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", 1784 + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", 1785 + "dev": true, 1786 + "license": "MIT", 1787 + "dependencies": { 1788 + "@types/node": "*", 1789 + "@types/qs": "*", 1790 + "@types/range-parser": "*", 1791 + "@types/send": "*" 1792 + } 1793 + }, 1794 + "node_modules/@types/http-errors": { 1795 + "version": "2.0.5", 1796 + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", 1797 + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", 1798 + "dev": true, 1799 + "license": "MIT" 1800 + }, 1720 1801 "node_modules/@types/linkify-it": { 1721 1802 "version": "5.0.0", 1722 1803 "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", ··· 1739 1820 "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", 1740 1821 "license": "MIT" 1741 1822 }, 1823 + "node_modules/@types/node": { 1824 + "version": "25.5.0", 1825 + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", 1826 + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", 1827 + "dev": true, 1828 + "license": "MIT", 1829 + "dependencies": { 1830 + "undici-types": "~7.18.0" 1831 + } 1832 + }, 1742 1833 "node_modules/@types/pako": { 1743 1834 "version": "2.0.4", 1744 1835 "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", 1745 1836 "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", 1746 1837 "license": "MIT" 1747 1838 }, 1839 + "node_modules/@types/qs": { 1840 + "version": "6.15.0", 1841 + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", 1842 + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", 1843 + "dev": true, 1844 + "license": "MIT" 1845 + }, 1748 1846 "node_modules/@types/raf": { 1749 1847 "version": "3.4.3", 1750 1848 "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", ··· 1752 1850 "license": "MIT", 1753 1851 "optional": true 1754 1852 }, 1853 + "node_modules/@types/range-parser": { 1854 + "version": "1.2.7", 1855 + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", 1856 + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", 1857 + "dev": true, 1858 + "license": "MIT" 1859 + }, 1860 + "node_modules/@types/send": { 1861 + "version": "1.2.1", 1862 + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", 1863 + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", 1864 + "dev": true, 1865 + "license": "MIT", 1866 + "dependencies": { 1867 + "@types/node": "*" 1868 + } 1869 + }, 1870 + "node_modules/@types/serve-static": { 1871 + "version": "2.2.0", 1872 + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", 1873 + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", 1874 + "dev": true, 1875 + "license": "MIT", 1876 + "dependencies": { 1877 + "@types/http-errors": "*", 1878 + "@types/node": "*" 1879 + } 1880 + }, 1755 1881 "node_modules/@types/trusted-types": { 1756 1882 "version": "2.0.7", 1757 1883 "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", ··· 1759 1885 "license": "MIT", 1760 1886 "optional": true 1761 1887 }, 1888 + "node_modules/@types/ws": { 1889 + "version": "8.18.1", 1890 + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", 1891 + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", 1892 + "dev": true, 1893 + "license": "MIT", 1894 + "dependencies": { 1895 + "@types/node": "*" 1896 + } 1897 + }, 1762 1898 "node_modules/@vitest/expect": { 1763 1899 "version": "4.1.0", 1764 1900 "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", ··· 2931 3067 "node": ">= 0.4" 2932 3068 } 2933 3069 }, 3070 + "node_modules/get-tsconfig": { 3071 + "version": "4.13.6", 3072 + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", 3073 + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", 3074 + "dev": true, 3075 + "license": "MIT", 3076 + "dependencies": { 3077 + "resolve-pkg-maps": "^1.0.0" 3078 + }, 3079 + "funding": { 3080 + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" 3081 + } 3082 + }, 2934 3083 "node_modules/github-from-package": { 2935 3084 "version": "0.0.0", 2936 3085 "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", ··· 4104 4253 "node": ">=0.10.0" 4105 4254 } 4106 4255 }, 4256 + "node_modules/resolve-pkg-maps": { 4257 + "version": "1.0.0", 4258 + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", 4259 + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", 4260 + "dev": true, 4261 + "license": "MIT", 4262 + "funding": { 4263 + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 4264 + } 4265 + }, 4107 4266 "node_modules/rgbcolor": { 4108 4267 "version": "1.0.1", 4109 4268 "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", ··· 4713 4872 "dev": true, 4714 4873 "license": "0BSD" 4715 4874 }, 4875 + "node_modules/tsx": { 4876 + "version": "4.21.0", 4877 + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", 4878 + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", 4879 + "dev": true, 4880 + "license": "MIT", 4881 + "dependencies": { 4882 + "esbuild": "~0.27.0", 4883 + "get-tsconfig": "^4.7.5" 4884 + }, 4885 + "bin": { 4886 + "tsx": "dist/cli.mjs" 4887 + }, 4888 + "engines": { 4889 + "node": ">=18.0.0" 4890 + }, 4891 + "optionalDependencies": { 4892 + "fsevents": "~2.3.3" 4893 + } 4894 + }, 4895 + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { 4896 + "version": "0.27.4", 4897 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", 4898 + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", 4899 + "cpu": [ 4900 + "ppc64" 4901 + ], 4902 + "dev": true, 4903 + "license": "MIT", 4904 + "optional": true, 4905 + "os": [ 4906 + "aix" 4907 + ], 4908 + "engines": { 4909 + "node": ">=18" 4910 + } 4911 + }, 4912 + "node_modules/tsx/node_modules/@esbuild/android-arm": { 4913 + "version": "0.27.4", 4914 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", 4915 + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", 4916 + "cpu": [ 4917 + "arm" 4918 + ], 4919 + "dev": true, 4920 + "license": "MIT", 4921 + "optional": true, 4922 + "os": [ 4923 + "android" 4924 + ], 4925 + "engines": { 4926 + "node": ">=18" 4927 + } 4928 + }, 4929 + "node_modules/tsx/node_modules/@esbuild/android-arm64": { 4930 + "version": "0.27.4", 4931 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", 4932 + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", 4933 + "cpu": [ 4934 + "arm64" 4935 + ], 4936 + "dev": true, 4937 + "license": "MIT", 4938 + "optional": true, 4939 + "os": [ 4940 + "android" 4941 + ], 4942 + "engines": { 4943 + "node": ">=18" 4944 + } 4945 + }, 4946 + "node_modules/tsx/node_modules/@esbuild/android-x64": { 4947 + "version": "0.27.4", 4948 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", 4949 + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", 4950 + "cpu": [ 4951 + "x64" 4952 + ], 4953 + "dev": true, 4954 + "license": "MIT", 4955 + "optional": true, 4956 + "os": [ 4957 + "android" 4958 + ], 4959 + "engines": { 4960 + "node": ">=18" 4961 + } 4962 + }, 4963 + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { 4964 + "version": "0.27.4", 4965 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", 4966 + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", 4967 + "cpu": [ 4968 + "arm64" 4969 + ], 4970 + "dev": true, 4971 + "license": "MIT", 4972 + "optional": true, 4973 + "os": [ 4974 + "darwin" 4975 + ], 4976 + "engines": { 4977 + "node": ">=18" 4978 + } 4979 + }, 4980 + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { 4981 + "version": "0.27.4", 4982 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", 4983 + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", 4984 + "cpu": [ 4985 + "x64" 4986 + ], 4987 + "dev": true, 4988 + "license": "MIT", 4989 + "optional": true, 4990 + "os": [ 4991 + "darwin" 4992 + ], 4993 + "engines": { 4994 + "node": ">=18" 4995 + } 4996 + }, 4997 + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { 4998 + "version": "0.27.4", 4999 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", 5000 + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", 5001 + "cpu": [ 5002 + "arm64" 5003 + ], 5004 + "dev": true, 5005 + "license": "MIT", 5006 + "optional": true, 5007 + "os": [ 5008 + "freebsd" 5009 + ], 5010 + "engines": { 5011 + "node": ">=18" 5012 + } 5013 + }, 5014 + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { 5015 + "version": "0.27.4", 5016 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", 5017 + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", 5018 + "cpu": [ 5019 + "x64" 5020 + ], 5021 + "dev": true, 5022 + "license": "MIT", 5023 + "optional": true, 5024 + "os": [ 5025 + "freebsd" 5026 + ], 5027 + "engines": { 5028 + "node": ">=18" 5029 + } 5030 + }, 5031 + "node_modules/tsx/node_modules/@esbuild/linux-arm": { 5032 + "version": "0.27.4", 5033 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", 5034 + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", 5035 + "cpu": [ 5036 + "arm" 5037 + ], 5038 + "dev": true, 5039 + "license": "MIT", 5040 + "optional": true, 5041 + "os": [ 5042 + "linux" 5043 + ], 5044 + "engines": { 5045 + "node": ">=18" 5046 + } 5047 + }, 5048 + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { 5049 + "version": "0.27.4", 5050 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", 5051 + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", 5052 + "cpu": [ 5053 + "arm64" 5054 + ], 5055 + "dev": true, 5056 + "license": "MIT", 5057 + "optional": true, 5058 + "os": [ 5059 + "linux" 5060 + ], 5061 + "engines": { 5062 + "node": ">=18" 5063 + } 5064 + }, 5065 + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { 5066 + "version": "0.27.4", 5067 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", 5068 + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", 5069 + "cpu": [ 5070 + "ia32" 5071 + ], 5072 + "dev": true, 5073 + "license": "MIT", 5074 + "optional": true, 5075 + "os": [ 5076 + "linux" 5077 + ], 5078 + "engines": { 5079 + "node": ">=18" 5080 + } 5081 + }, 5082 + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { 5083 + "version": "0.27.4", 5084 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", 5085 + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", 5086 + "cpu": [ 5087 + "loong64" 5088 + ], 5089 + "dev": true, 5090 + "license": "MIT", 5091 + "optional": true, 5092 + "os": [ 5093 + "linux" 5094 + ], 5095 + "engines": { 5096 + "node": ">=18" 5097 + } 5098 + }, 5099 + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { 5100 + "version": "0.27.4", 5101 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", 5102 + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", 5103 + "cpu": [ 5104 + "mips64el" 5105 + ], 5106 + "dev": true, 5107 + "license": "MIT", 5108 + "optional": true, 5109 + "os": [ 5110 + "linux" 5111 + ], 5112 + "engines": { 5113 + "node": ">=18" 5114 + } 5115 + }, 5116 + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { 5117 + "version": "0.27.4", 5118 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", 5119 + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", 5120 + "cpu": [ 5121 + "ppc64" 5122 + ], 5123 + "dev": true, 5124 + "license": "MIT", 5125 + "optional": true, 5126 + "os": [ 5127 + "linux" 5128 + ], 5129 + "engines": { 5130 + "node": ">=18" 5131 + } 5132 + }, 5133 + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { 5134 + "version": "0.27.4", 5135 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", 5136 + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", 5137 + "cpu": [ 5138 + "riscv64" 5139 + ], 5140 + "dev": true, 5141 + "license": "MIT", 5142 + "optional": true, 5143 + "os": [ 5144 + "linux" 5145 + ], 5146 + "engines": { 5147 + "node": ">=18" 5148 + } 5149 + }, 5150 + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { 5151 + "version": "0.27.4", 5152 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", 5153 + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", 5154 + "cpu": [ 5155 + "s390x" 5156 + ], 5157 + "dev": true, 5158 + "license": "MIT", 5159 + "optional": true, 5160 + "os": [ 5161 + "linux" 5162 + ], 5163 + "engines": { 5164 + "node": ">=18" 5165 + } 5166 + }, 5167 + "node_modules/tsx/node_modules/@esbuild/linux-x64": { 5168 + "version": "0.27.4", 5169 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", 5170 + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", 5171 + "cpu": [ 5172 + "x64" 5173 + ], 5174 + "dev": true, 5175 + "license": "MIT", 5176 + "optional": true, 5177 + "os": [ 5178 + "linux" 5179 + ], 5180 + "engines": { 5181 + "node": ">=18" 5182 + } 5183 + }, 5184 + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { 5185 + "version": "0.27.4", 5186 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", 5187 + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", 5188 + "cpu": [ 5189 + "arm64" 5190 + ], 5191 + "dev": true, 5192 + "license": "MIT", 5193 + "optional": true, 5194 + "os": [ 5195 + "netbsd" 5196 + ], 5197 + "engines": { 5198 + "node": ">=18" 5199 + } 5200 + }, 5201 + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { 5202 + "version": "0.27.4", 5203 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", 5204 + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", 5205 + "cpu": [ 5206 + "x64" 5207 + ], 5208 + "dev": true, 5209 + "license": "MIT", 5210 + "optional": true, 5211 + "os": [ 5212 + "netbsd" 5213 + ], 5214 + "engines": { 5215 + "node": ">=18" 5216 + } 5217 + }, 5218 + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { 5219 + "version": "0.27.4", 5220 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", 5221 + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", 5222 + "cpu": [ 5223 + "arm64" 5224 + ], 5225 + "dev": true, 5226 + "license": "MIT", 5227 + "optional": true, 5228 + "os": [ 5229 + "openbsd" 5230 + ], 5231 + "engines": { 5232 + "node": ">=18" 5233 + } 5234 + }, 5235 + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { 5236 + "version": "0.27.4", 5237 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", 5238 + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", 5239 + "cpu": [ 5240 + "x64" 5241 + ], 5242 + "dev": true, 5243 + "license": "MIT", 5244 + "optional": true, 5245 + "os": [ 5246 + "openbsd" 5247 + ], 5248 + "engines": { 5249 + "node": ">=18" 5250 + } 5251 + }, 5252 + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { 5253 + "version": "0.27.4", 5254 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", 5255 + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", 5256 + "cpu": [ 5257 + "arm64" 5258 + ], 5259 + "dev": true, 5260 + "license": "MIT", 5261 + "optional": true, 5262 + "os": [ 5263 + "openharmony" 5264 + ], 5265 + "engines": { 5266 + "node": ">=18" 5267 + } 5268 + }, 5269 + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { 5270 + "version": "0.27.4", 5271 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", 5272 + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", 5273 + "cpu": [ 5274 + "x64" 5275 + ], 5276 + "dev": true, 5277 + "license": "MIT", 5278 + "optional": true, 5279 + "os": [ 5280 + "sunos" 5281 + ], 5282 + "engines": { 5283 + "node": ">=18" 5284 + } 5285 + }, 5286 + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { 5287 + "version": "0.27.4", 5288 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", 5289 + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", 5290 + "cpu": [ 5291 + "arm64" 5292 + ], 5293 + "dev": true, 5294 + "license": "MIT", 5295 + "optional": true, 5296 + "os": [ 5297 + "win32" 5298 + ], 5299 + "engines": { 5300 + "node": ">=18" 5301 + } 5302 + }, 5303 + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { 5304 + "version": "0.27.4", 5305 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", 5306 + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", 5307 + "cpu": [ 5308 + "ia32" 5309 + ], 5310 + "dev": true, 5311 + "license": "MIT", 5312 + "optional": true, 5313 + "os": [ 5314 + "win32" 5315 + ], 5316 + "engines": { 5317 + "node": ">=18" 5318 + } 5319 + }, 5320 + "node_modules/tsx/node_modules/@esbuild/win32-x64": { 5321 + "version": "0.27.4", 5322 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", 5323 + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", 5324 + "cpu": [ 5325 + "x64" 5326 + ], 5327 + "dev": true, 5328 + "license": "MIT", 5329 + "optional": true, 5330 + "os": [ 5331 + "win32" 5332 + ], 5333 + "engines": { 5334 + "node": ">=18" 5335 + } 5336 + }, 5337 + "node_modules/tsx/node_modules/esbuild": { 5338 + "version": "0.27.4", 5339 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", 5340 + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", 5341 + "dev": true, 5342 + "hasInstallScript": true, 5343 + "license": "MIT", 5344 + "bin": { 5345 + "esbuild": "bin/esbuild" 5346 + }, 5347 + "engines": { 5348 + "node": ">=18" 5349 + }, 5350 + "optionalDependencies": { 5351 + "@esbuild/aix-ppc64": "0.27.4", 5352 + "@esbuild/android-arm": "0.27.4", 5353 + "@esbuild/android-arm64": "0.27.4", 5354 + "@esbuild/android-x64": "0.27.4", 5355 + "@esbuild/darwin-arm64": "0.27.4", 5356 + "@esbuild/darwin-x64": "0.27.4", 5357 + "@esbuild/freebsd-arm64": "0.27.4", 5358 + "@esbuild/freebsd-x64": "0.27.4", 5359 + "@esbuild/linux-arm": "0.27.4", 5360 + "@esbuild/linux-arm64": "0.27.4", 5361 + "@esbuild/linux-ia32": "0.27.4", 5362 + "@esbuild/linux-loong64": "0.27.4", 5363 + "@esbuild/linux-mips64el": "0.27.4", 5364 + "@esbuild/linux-ppc64": "0.27.4", 5365 + "@esbuild/linux-riscv64": "0.27.4", 5366 + "@esbuild/linux-s390x": "0.27.4", 5367 + "@esbuild/linux-x64": "0.27.4", 5368 + "@esbuild/netbsd-arm64": "0.27.4", 5369 + "@esbuild/netbsd-x64": "0.27.4", 5370 + "@esbuild/openbsd-arm64": "0.27.4", 5371 + "@esbuild/openbsd-x64": "0.27.4", 5372 + "@esbuild/openharmony-arm64": "0.27.4", 5373 + "@esbuild/sunos-x64": "0.27.4", 5374 + "@esbuild/win32-arm64": "0.27.4", 5375 + "@esbuild/win32-ia32": "0.27.4", 5376 + "@esbuild/win32-x64": "0.27.4" 5377 + } 5378 + }, 4716 5379 "node_modules/tunnel-agent": { 4717 5380 "version": "0.6.0", 4718 5381 "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", ··· 4753 5416 "node": ">= 0.6" 4754 5417 } 4755 5418 }, 5419 + "node_modules/typescript": { 5420 + "version": "5.9.3", 5421 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 5422 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 5423 + "dev": true, 5424 + "license": "Apache-2.0", 5425 + "bin": { 5426 + "tsc": "bin/tsc", 5427 + "tsserver": "bin/tsserver" 5428 + }, 5429 + "engines": { 5430 + "node": ">=14.17" 5431 + } 5432 + }, 4756 5433 "node_modules/uc.micro": { 4757 5434 "version": "2.1.0", 4758 5435 "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", ··· 4774 5451 "engines": { 4775 5452 "node": ">=20.18.1" 4776 5453 } 5454 + }, 5455 + "node_modules/undici-types": { 5456 + "version": "7.18.2", 5457 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", 5458 + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", 5459 + "dev": true, 5460 + "license": "MIT" 4777 5461 }, 4778 5462 "node_modules/unpipe": { 4779 5463 "version": "1.0.0",
+12 -4
package.json
··· 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": { 7 - "dev": "concurrently -n server,vite -c blue,green \"node server.js\" \"vite\"", 7 + "dev": "concurrently -n server,vite -c blue,green \"tsx watch server/index.ts\" \"vite\"", 8 8 "build": "vite build", 9 - "start": "node server.js", 10 - "preview": "npm run build && node server.js", 11 - "test": "vitest run" 9 + "start": "tsx server/index.ts", 10 + "preview": "npm run build && tsx server/index.ts", 11 + "test": "vitest run", 12 + "typecheck": "tsc --noEmit" 12 13 }, 13 14 "dependencies": { 14 15 "@tiptap/core": "^2.11.0", ··· 50 51 "yjs": "^13.6.20" 51 52 }, 52 53 "devDependencies": { 54 + "@types/better-sqlite3": "^7.6.13", 55 + "@types/compression": "^1.8.1", 56 + "@types/express": "^5.0.6", 57 + "@types/node": "^25.5.0", 58 + "@types/ws": "^8.18.1", 53 59 "concurrently": "^9.1.0", 54 60 "jsdom": "^29.0.0", 55 61 "jszip": "^3.10.1", 62 + "tsx": "^4.21.0", 63 + "typescript": "^5.9.3", 56 64 "vite": "^6.0.0", 57 65 "vitest": "^4.1.0" 58 66 }
-321
server.js
··· 1 - import express from 'express'; 2 - import { createServer } from 'http'; 3 - import { createServer as createHttpsServer } from 'https'; 4 - import { readFileSync, existsSync, renameSync } from 'fs'; 5 - import { WebSocketServer } from 'ws'; 6 - import Database from 'better-sqlite3'; 7 - import { randomUUID } from 'crypto'; 8 - import path from 'path'; 9 - import { fileURLToPath } from 'url'; 10 - import compression from 'compression'; 11 - 12 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 13 - const DATA_DIR = process.env.DATA_DIR || __dirname; 14 - const PORT = process.env.PORT || 3000; 15 - const TS_CERT_DIR = '/var/lib/tailscale/certs'; 16 - const TLS_CERT = process.env.TLS_CERT || ( 17 - existsSync(path.join(DATA_DIR, 'cert.pem')) ? path.join(DATA_DIR, 'cert.pem') : 18 - path.join(TS_CERT_DIR, 'cert.pem') 19 - ); 20 - const TLS_KEY = process.env.TLS_KEY || ( 21 - existsSync(path.join(DATA_DIR, 'key.pem')) ? path.join(DATA_DIR, 'key.pem') : 22 - path.join(TS_CERT_DIR, 'key.pem') 23 - ); 24 - 25 - // --- Database --- 26 - // Migrate legacy database filename 27 - const toolsDbPath = path.join(DATA_DIR, 'tools.db'); 28 - const legacyDbPath = path.join(DATA_DIR, 'crypt.db'); 29 - if (!existsSync(toolsDbPath) && existsSync(legacyDbPath)) { 30 - renameSync(legacyDbPath, toolsDbPath); 31 - // Also migrate WAL/SHM files if present 32 - if (existsSync(legacyDbPath + '-wal')) renameSync(legacyDbPath + '-wal', toolsDbPath + '-wal'); 33 - if (existsSync(legacyDbPath + '-shm')) renameSync(legacyDbPath + '-shm', toolsDbPath + '-shm'); 34 - console.log('Migrated crypt.db → tools.db'); 35 - } 36 - const db = new Database(toolsDbPath); 37 - db.pragma('journal_mode = WAL'); 38 - db.exec(` 39 - CREATE TABLE IF NOT EXISTS documents ( 40 - id TEXT PRIMARY KEY, 41 - type TEXT NOT NULL CHECK(type IN ('doc','sheet')), 42 - name_encrypted TEXT, 43 - snapshot BLOB, 44 - share_mode TEXT DEFAULT 'edit', 45 - expires_at TEXT, 46 - created_at TEXT DEFAULT (datetime('now')), 47 - updated_at TEXT DEFAULT (datetime('now')) 48 - ) 49 - `); 50 - 51 - // Migration: add share_mode and expires_at columns if missing (existing databases) 52 - try { 53 - db.prepare("SELECT share_mode FROM documents LIMIT 1").get(); 54 - } catch { 55 - db.exec("ALTER TABLE documents ADD COLUMN share_mode TEXT DEFAULT 'edit'"); 56 - console.log('Migrated: added share_mode column'); 57 - } 58 - try { 59 - db.prepare("SELECT expires_at FROM documents LIMIT 1").get(); 60 - } catch { 61 - db.exec("ALTER TABLE documents ADD COLUMN expires_at TEXT"); 62 - console.log('Migrated: added expires_at column'); 63 - } 64 - db.exec(` 65 - CREATE TABLE IF NOT EXISTS versions ( 66 - id TEXT PRIMARY KEY, 67 - document_id TEXT NOT NULL, 68 - snapshot BLOB NOT NULL, 69 - created_at TEXT DEFAULT (datetime('now')), 70 - metadata TEXT 71 - ) 72 - `); 73 - 74 - const MAX_VERSIONS_PER_DOC = 50; 75 - 76 - const stmts = { 77 - insert: db.prepare('INSERT INTO documents (id, type, name_encrypted) VALUES (?, ?, ?)'), 78 - getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, created_at, updated_at FROM documents WHERE id = ?'), 79 - getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, created_at, updated_at FROM documents ORDER BY updated_at DESC'), 80 - getSnapshot: db.prepare('SELECT snapshot, expires_at FROM documents WHERE id = ?'), 81 - putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"), 82 - putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"), 83 - deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'), 84 - // Version history 85 - insertVersion: db.prepare('INSERT INTO versions (id, document_id, snapshot, metadata) VALUES (?, ?, ?, ?)'), 86 - getVersions: db.prepare('SELECT id, document_id, created_at, metadata FROM versions WHERE document_id = ? ORDER BY rowid DESC LIMIT 50'), 87 - getVersionSnapshot: db.prepare('SELECT snapshot FROM versions WHERE id = ? AND document_id = ?'), 88 - countVersions: db.prepare('SELECT COUNT(*) as count FROM versions WHERE document_id = ?'), 89 - // Sharing 90 - updateShare: db.prepare("UPDATE documents SET share_mode = ?, expires_at = ?, updated_at = datetime('now') WHERE id = ?"), 91 - }; 92 - 93 - // --- Express --- 94 - const app = express(); 95 - app.use(compression()); 96 - app.use(express.json({ limit: '1mb' })); 97 - 98 - // API routes 99 - app.post('/api/documents', (req, res) => { 100 - const id = randomUUID(); 101 - const { type, name_encrypted } = req.body; 102 - if (!type || !['doc', 'sheet'].includes(type)) { 103 - return res.status(400).json({ error: 'type must be doc or sheet' }); 104 - } 105 - stmts.insert.run(id, type, name_encrypted || null); 106 - res.json({ id }); 107 - }); 108 - 109 - app.get('/api/documents', (_req, res) => { 110 - res.json(stmts.getAll.all()); 111 - }); 112 - 113 - app.get('/api/documents/:id', (req, res) => { 114 - const doc = stmts.getOne.get(req.params.id); 115 - if (!doc) return res.status(404).json({ error: 'Not found' }); 116 - res.json(doc); 117 - }); 118 - 119 - app.delete('/api/documents/:id', (req, res) => { 120 - stmts.deleteDoc.run(req.params.id); 121 - res.json({ ok: true }); 122 - }); 123 - 124 - app.put('/api/documents/:id/name', (req, res) => { 125 - const { name_encrypted } = req.body; 126 - stmts.putName.run(name_encrypted, req.params.id); 127 - res.json({ ok: true }); 128 - }); 129 - 130 - app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req, res) => { 131 - stmts.putSnapshot.run(req.body, req.params.id); 132 - res.json({ ok: true }); 133 - }); 134 - 135 - app.get('/api/documents/:id/snapshot', (req, res) => { 136 - const row = stmts.getSnapshot.get(req.params.id); 137 - if (!row || !row.snapshot) return res.status(404).json({ error: 'No snapshot' }); 138 - 139 - // Check link expiry 140 - if (row.expires_at) { 141 - const expiresAt = new Date(row.expires_at); 142 - if (expiresAt <= new Date()) { 143 - return res.status(410).json({ error: 'Document link has expired' }); 144 - } 145 - } 146 - 147 - res.type('application/octet-stream').send(row.snapshot); 148 - }); 149 - 150 - // --- Sharing --- 151 - app.put('/api/documents/:id/share', (req, res) => { 152 - const doc = stmts.getOne.get(req.params.id); 153 - if (!doc) return res.status(404).json({ error: 'Not found' }); 154 - 155 - const { share_mode, expires_at } = req.body; 156 - 157 - // Validate share_mode 158 - if (share_mode && !['edit', 'view'].includes(share_mode)) { 159 - return res.status(400).json({ error: 'share_mode must be "edit" or "view"' }); 160 - } 161 - 162 - // Validate expires_at if provided 163 - if (expires_at !== null && expires_at !== undefined && expires_at !== '') { 164 - const d = new Date(expires_at); 165 - if (isNaN(d.getTime())) { 166 - return res.status(400).json({ error: 'Invalid expires_at date' }); 167 - } 168 - } 169 - 170 - stmts.updateShare.run( 171 - share_mode || doc.share_mode, 172 - expires_at === null ? null : (expires_at || doc.expires_at), 173 - req.params.id 174 - ); 175 - 176 - const updated = stmts.getOne.get(req.params.id); 177 - res.json({ 178 - share_mode: updated.share_mode, 179 - expires_at: updated.expires_at, 180 - }); 181 - }); 182 - 183 - // --- Version History --- 184 - app.get('/api/documents/:id/versions', (req, res) => { 185 - const versions = stmts.getVersions.all(req.params.id); 186 - res.json(versions.map(v => ({ 187 - ...v, 188 - metadata: v.metadata ? JSON.parse(v.metadata) : null, 189 - }))); 190 - }); 191 - 192 - app.post('/api/documents/:id/versions', express.raw({ limit: '50mb', type: '*/*' }), (req, res) => { 193 - const docId = req.params.id; 194 - const id = randomUUID(); 195 - const metadata = req.headers['x-version-metadata'] || null; 196 - stmts.insertVersion.run(id, docId, req.body, metadata); 197 - // FIFO: prune if over limit 198 - const { count } = stmts.countVersions.get(docId); 199 - if (count > MAX_VERSIONS_PER_DOC) { 200 - const excess = count - MAX_VERSIONS_PER_DOC; 201 - db.prepare(` 202 - DELETE FROM versions WHERE id IN ( 203 - SELECT id FROM versions WHERE document_id = ? 204 - ORDER BY created_at ASC 205 - LIMIT ? 206 - ) 207 - `).run(docId, excess); 208 - } 209 - res.json({ id }); 210 - }); 211 - 212 - app.get('/api/documents/:id/versions/:versionId', (req, res) => { 213 - const row = stmts.getVersionSnapshot.get(req.params.versionId, req.params.id); 214 - if (!row || !row.snapshot) return res.status(404).json({ error: 'Version not found' }); 215 - res.type('application/octet-stream').send(row.snapshot); 216 - }); 217 - 218 - // Health check 219 - app.get('/health', (_req, res) => { 220 - try { 221 - db.prepare('SELECT 1').get(); 222 - res.json({ status: 'ok', rooms: rooms.size }); 223 - } catch (err) { 224 - res.status(500).json({ status: 'error', error: err.message }); 225 - } 226 - }); 227 - 228 - // Static files (production build) 229 - const distPath = path.join(__dirname, 'dist'); 230 - app.use(express.static(distPath)); 231 - 232 - // SPA fallback: serve the correct index.html for each sub-app 233 - // /docs/:id and /sheets/:id → editor pages 234 - app.get('/docs/:id', (_req, res) => res.sendFile(path.join(distPath, 'docs/index.html'))); 235 - app.get('/sheets/:id', (_req, res) => res.sendFile(path.join(distPath, 'sheets/index.html'))); 236 - // /docs, /sheets, / → landing page (document list + create) 237 - app.get('*', (_req, res) => res.sendFile(path.join(distPath, 'index.html'))); 238 - 239 - // --- HTTP + HTTPS + WebSocket server --- 240 - const server = createServer(app); 241 - 242 - // Self-signed HTTPS for crypto.subtle secure context (Tailscale encrypts transport) 243 - let httpsServer; 244 - if (existsSync(TLS_CERT) && existsSync(TLS_KEY)) { 245 - try { 246 - httpsServer = createHttpsServer({ 247 - cert: readFileSync(TLS_CERT), 248 - key: readFileSync(TLS_KEY), 249 - }, app); 250 - console.log('HTTPS enabled with self-signed cert'); 251 - } catch (err) { 252 - console.warn('Failed to load TLS cert, HTTPS disabled:', err.message); 253 - } 254 - } 255 - 256 - const wss = new WebSocketServer({ noServer: true }); 257 - 258 - // Room management for E2EE relay 259 - const rooms = new Map(); // roomId → Set<ws> 260 - 261 - function handleUpgrade(request, socket, head) { 262 - const url = new URL(request.url, 'http://localhost'); 263 - if (url.pathname !== '/ws') { 264 - socket.destroy(); 265 - return; 266 - } 267 - wss.handleUpgrade(request, socket, head, (ws) => { 268 - const room = url.searchParams.get('room'); 269 - if (!room) { ws.close(); return; } 270 - 271 - if (!rooms.has(room)) rooms.set(room, new Set()); 272 - const peers = rooms.get(room); 273 - peers.add(ws); 274 - 275 - // Tell this client how many peers are connected (for sync) 276 - ws.send(JSON.stringify({ type: 'peer-count', count: peers.size - 1 })); 277 - 278 - // Notify others that a new peer joined 279 - for (const peer of peers) { 280 - if (peer !== ws && peer.readyState === 1) { 281 - peer.send(JSON.stringify({ type: 'peer-joined' })); 282 - } 283 - } 284 - 285 - ws.on('message', (data, isBinary) => { 286 - // Relay encrypted messages to all other clients in the room 287 - for (const peer of peers) { 288 - if (peer !== ws && peer.readyState === 1) { 289 - peer.send(data, { binary: isBinary }); 290 - } 291 - } 292 - }); 293 - 294 - ws.on('close', () => { 295 - peers.delete(ws); 296 - if (peers.size === 0) { 297 - rooms.delete(room); 298 - } else { 299 - for (const peer of peers) { 300 - if (peer.readyState === 1) { 301 - peer.send(JSON.stringify({ type: 'peer-left', count: peers.size })); 302 - } 303 - } 304 - } 305 - }); 306 - }); 307 - } 308 - 309 - server.on('upgrade', handleUpgrade); 310 - if (httpsServer) httpsServer.on('upgrade', handleUpgrade); 311 - 312 - server.listen(PORT, () => { 313 - console.log(`Tools running on http://localhost:${PORT}`); 314 - }); 315 - 316 - if (httpsServer) { 317 - const HTTPS_PORT = process.env.HTTPS_PORT || 3443; 318 - httpsServer.listen(HTTPS_PORT, () => { 319 - console.log(`Tools HTTPS on https://localhost:${HTTPS_PORT}`); 320 - }); 321 - }
+413
server/index.ts
··· 1 + import express, { type Request, type Response } from 'express'; 2 + import { createServer } from 'http'; 3 + import { createServer as createHttpsServer, type Server as HttpsServer } from 'https'; 4 + import { readFileSync, existsSync, renameSync } from 'fs'; 5 + import { WebSocketServer, type WebSocket } from 'ws'; 6 + import Database, { type Statement } from 'better-sqlite3'; 7 + import { randomUUID } from 'crypto'; 8 + import path from 'path'; 9 + import { fileURLToPath } from 'url'; 10 + import compression from 'compression'; 11 + import type { IncomingMessage } from 'http'; 12 + import type { Duplex } from 'stream'; 13 + 14 + // --- Interfaces --- 15 + 16 + interface DocumentRow { 17 + id: string; 18 + type: 'doc' | 'sheet'; 19 + name_encrypted: string | null; 20 + snapshot: Buffer | null; 21 + share_mode: 'edit' | 'view' | null; 22 + expires_at: string | null; 23 + created_at: string; 24 + updated_at: string; 25 + } 26 + 27 + interface DocumentListRow { 28 + id: string; 29 + type: 'doc' | 'sheet'; 30 + name_encrypted: string | null; 31 + share_mode: 'edit' | 'view' | null; 32 + expires_at: string | null; 33 + created_at: string; 34 + updated_at: string; 35 + } 36 + 37 + interface SnapshotRow { 38 + snapshot: Buffer | null; 39 + expires_at: string | null; 40 + } 41 + 42 + interface VersionRow { 43 + id: string; 44 + document_id: string; 45 + created_at: string; 46 + metadata: string | null; 47 + } 48 + 49 + interface VersionSnapshotRow { 50 + snapshot: Buffer; 51 + } 52 + 53 + interface VersionCountRow { 54 + count: number; 55 + } 56 + 57 + interface WsControlMessage { 58 + type: 'peer-count' | 'peer-joined' | 'peer-left'; 59 + count?: number; 60 + } 61 + 62 + interface CreateDocumentBody { 63 + type?: string; 64 + name_encrypted?: string; 65 + } 66 + 67 + interface UpdateNameBody { 68 + name_encrypted?: string; 69 + } 70 + 71 + interface UpdateShareBody { 72 + share_mode?: string; 73 + expires_at?: string | null; 74 + } 75 + 76 + interface PreparedStatements { 77 + insert: Statement; 78 + getOne: Statement; 79 + getAll: Statement; 80 + getSnapshot: Statement; 81 + putSnapshot: Statement; 82 + putName: Statement; 83 + deleteDoc: Statement; 84 + insertVersion: Statement; 85 + getVersions: Statement; 86 + getVersionSnapshot: Statement; 87 + countVersions: Statement; 88 + updateShare: Statement; 89 + } 90 + 91 + // --- Setup --- 92 + 93 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 94 + const PROJECT_ROOT = path.resolve(__dirname, '..'); 95 + const DATA_DIR = process.env['DATA_DIR'] || PROJECT_ROOT; 96 + const PORT = process.env['PORT'] || 3000; 97 + const TS_CERT_DIR = '/var/lib/tailscale/certs'; 98 + const TLS_CERT = process.env['TLS_CERT'] || ( 99 + existsSync(path.join(DATA_DIR, 'cert.pem')) ? path.join(DATA_DIR, 'cert.pem') : 100 + path.join(TS_CERT_DIR, 'cert.pem') 101 + ); 102 + const TLS_KEY = process.env['TLS_KEY'] || ( 103 + existsSync(path.join(DATA_DIR, 'key.pem')) ? path.join(DATA_DIR, 'key.pem') : 104 + path.join(TS_CERT_DIR, 'key.pem') 105 + ); 106 + 107 + // --- Database --- 108 + // Migrate legacy database filename 109 + const toolsDbPath = path.join(DATA_DIR, 'tools.db'); 110 + const legacyDbPath = path.join(DATA_DIR, 'crypt.db'); 111 + if (!existsSync(toolsDbPath) && existsSync(legacyDbPath)) { 112 + renameSync(legacyDbPath, toolsDbPath); 113 + // Also migrate WAL/SHM files if present 114 + if (existsSync(legacyDbPath + '-wal')) renameSync(legacyDbPath + '-wal', toolsDbPath + '-wal'); 115 + if (existsSync(legacyDbPath + '-shm')) renameSync(legacyDbPath + '-shm', toolsDbPath + '-shm'); 116 + console.log('Migrated crypt.db → tools.db'); 117 + } 118 + const db = new Database(toolsDbPath); 119 + db.pragma('journal_mode = WAL'); 120 + db.exec(` 121 + CREATE TABLE IF NOT EXISTS documents ( 122 + id TEXT PRIMARY KEY, 123 + type TEXT NOT NULL CHECK(type IN ('doc','sheet')), 124 + name_encrypted TEXT, 125 + snapshot BLOB, 126 + share_mode TEXT DEFAULT 'edit', 127 + expires_at TEXT, 128 + created_at TEXT DEFAULT (datetime('now')), 129 + updated_at TEXT DEFAULT (datetime('now')) 130 + ) 131 + `); 132 + 133 + // Migration: add share_mode and expires_at columns if missing (existing databases) 134 + try { 135 + db.prepare("SELECT share_mode FROM documents LIMIT 1").get(); 136 + } catch { 137 + db.exec("ALTER TABLE documents ADD COLUMN share_mode TEXT DEFAULT 'edit'"); 138 + console.log('Migrated: added share_mode column'); 139 + } 140 + try { 141 + db.prepare("SELECT expires_at FROM documents LIMIT 1").get(); 142 + } catch { 143 + db.exec("ALTER TABLE documents ADD COLUMN expires_at TEXT"); 144 + console.log('Migrated: added expires_at column'); 145 + } 146 + db.exec(` 147 + CREATE TABLE IF NOT EXISTS versions ( 148 + id TEXT PRIMARY KEY, 149 + document_id TEXT NOT NULL, 150 + snapshot BLOB NOT NULL, 151 + created_at TEXT DEFAULT (datetime('now')), 152 + metadata TEXT 153 + ) 154 + `); 155 + 156 + const MAX_VERSIONS_PER_DOC = 50; 157 + 158 + const stmts: PreparedStatements = { 159 + insert: db.prepare('INSERT INTO documents (id, type, name_encrypted) VALUES (?, ?, ?)'), 160 + getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, created_at, updated_at FROM documents WHERE id = ?'), 161 + getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, created_at, updated_at FROM documents ORDER BY updated_at DESC'), 162 + getSnapshot: db.prepare('SELECT snapshot, expires_at FROM documents WHERE id = ?'), 163 + putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"), 164 + putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"), 165 + deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'), 166 + // Version history 167 + insertVersion: db.prepare('INSERT INTO versions (id, document_id, snapshot, metadata) VALUES (?, ?, ?, ?)'), 168 + getVersions: db.prepare('SELECT id, document_id, created_at, metadata FROM versions WHERE document_id = ? ORDER BY rowid DESC LIMIT 50'), 169 + getVersionSnapshot: db.prepare('SELECT snapshot FROM versions WHERE id = ? AND document_id = ?'), 170 + countVersions: db.prepare('SELECT COUNT(*) as count FROM versions WHERE document_id = ?'), 171 + // Sharing 172 + updateShare: db.prepare("UPDATE documents SET share_mode = ?, expires_at = ?, updated_at = datetime('now') WHERE id = ?"), 173 + }; 174 + 175 + // --- Express --- 176 + const app = express(); 177 + app.use(compression()); 178 + app.use(express.json({ limit: '1mb' })); 179 + 180 + // API routes 181 + app.post('/api/documents', (req: Request<Record<string, string>, unknown, CreateDocumentBody>, res: Response) => { 182 + const id = randomUUID(); 183 + const { type, name_encrypted } = req.body; 184 + if (!type || !['doc', 'sheet'].includes(type)) { 185 + res.status(400).json({ error: 'type must be doc or sheet' }); 186 + return; 187 + } 188 + stmts.insert.run(id, type, name_encrypted || null); 189 + res.json({ id }); 190 + }); 191 + 192 + app.get('/api/documents', (_req: Request, res: Response) => { 193 + res.json(stmts.getAll.all() as DocumentListRow[]); 194 + }); 195 + 196 + app.get('/api/documents/:id', (req: Request<{ id: string }>, res: Response) => { 197 + const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 198 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 199 + res.json(doc); 200 + }); 201 + 202 + app.delete('/api/documents/:id', (req: Request<{ id: string }>, res: Response) => { 203 + stmts.deleteDoc.run(req.params.id); 204 + res.json({ ok: true }); 205 + }); 206 + 207 + app.put('/api/documents/:id/name', (req: Request<{ id: string }, unknown, UpdateNameBody>, res: Response) => { 208 + const { name_encrypted } = req.body; 209 + stmts.putName.run(name_encrypted, req.params.id); 210 + res.json({ ok: true }); 211 + }); 212 + 213 + app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req: Request<{ id: string }>, res: Response) => { 214 + stmts.putSnapshot.run(req.body, req.params.id); 215 + res.json({ ok: true }); 216 + }); 217 + 218 + app.get('/api/documents/:id/snapshot', (req: Request<{ id: string }>, res: Response) => { 219 + const row = stmts.getSnapshot.get(req.params.id) as SnapshotRow | undefined; 220 + if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 221 + 222 + // Check link expiry 223 + if (row.expires_at) { 224 + const expiresAt = new Date(row.expires_at); 225 + if (expiresAt <= new Date()) { 226 + res.status(410).json({ error: 'Document link has expired' }); 227 + return; 228 + } 229 + } 230 + 231 + res.type('application/octet-stream').send(row.snapshot); 232 + }); 233 + 234 + // --- Sharing --- 235 + app.put('/api/documents/:id/share', (req: Request<{ id: string }, unknown, UpdateShareBody>, res: Response) => { 236 + const doc = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 237 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 238 + 239 + const { share_mode, expires_at } = req.body; 240 + 241 + // Validate share_mode 242 + if (share_mode && !['edit', 'view'].includes(share_mode)) { 243 + res.status(400).json({ error: 'share_mode must be "edit" or "view"' }); 244 + return; 245 + } 246 + 247 + // Validate expires_at if provided 248 + if (expires_at !== null && expires_at !== undefined && expires_at !== '') { 249 + const d = new Date(expires_at); 250 + if (isNaN(d.getTime())) { 251 + res.status(400).json({ error: 'Invalid expires_at date' }); 252 + return; 253 + } 254 + } 255 + 256 + stmts.updateShare.run( 257 + share_mode || doc.share_mode, 258 + expires_at === null ? null : (expires_at || doc.expires_at), 259 + req.params.id 260 + ); 261 + 262 + const updated = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 263 + res.json({ 264 + share_mode: updated?.share_mode, 265 + expires_at: updated?.expires_at, 266 + }); 267 + }); 268 + 269 + // --- Version History --- 270 + app.get('/api/documents/:id/versions', (req: Request<{ id: string }>, res: Response) => { 271 + const versions = stmts.getVersions.all(req.params.id) as VersionRow[]; 272 + res.json(versions.map(v => ({ 273 + ...v, 274 + metadata: v.metadata ? JSON.parse(v.metadata) as unknown : null, 275 + }))); 276 + }); 277 + 278 + app.post('/api/documents/:id/versions', express.raw({ limit: '50mb', type: '*/*' }), (req: Request<{ id: string }>, res: Response) => { 279 + const docId = req.params.id; 280 + const id = randomUUID(); 281 + const metadata = req.headers['x-version-metadata'] as string | undefined ?? null; 282 + stmts.insertVersion.run(id, docId, req.body, metadata); 283 + // FIFO: prune if over limit 284 + const countRow = stmts.countVersions.get(docId) as VersionCountRow | undefined; 285 + const count = countRow?.count ?? 0; 286 + if (count > MAX_VERSIONS_PER_DOC) { 287 + const excess = count - MAX_VERSIONS_PER_DOC; 288 + db.prepare(` 289 + DELETE FROM versions WHERE id IN ( 290 + SELECT id FROM versions WHERE document_id = ? 291 + ORDER BY created_at ASC 292 + LIMIT ? 293 + ) 294 + `).run(docId, excess); 295 + } 296 + res.json({ id }); 297 + }); 298 + 299 + app.get('/api/documents/:id/versions/:versionId', (req: Request<{ id: string; versionId: string }>, res: Response) => { 300 + const row = stmts.getVersionSnapshot.get(req.params.versionId, req.params.id) as VersionSnapshotRow | undefined; 301 + if (!row || !row.snapshot) { res.status(404).json({ error: 'Version not found' }); return; } 302 + res.type('application/octet-stream').send(row.snapshot); 303 + }); 304 + 305 + // Health check 306 + app.get('/health', (_req: Request, res: Response) => { 307 + try { 308 + db.prepare('SELECT 1').get(); 309 + res.json({ status: 'ok', rooms: rooms.size }); 310 + } catch (err: unknown) { 311 + const message = err instanceof Error ? err.message : 'Unknown error'; 312 + res.status(500).json({ status: 'error', error: message }); 313 + } 314 + }); 315 + 316 + // Static files (production build) 317 + const distPath = path.join(PROJECT_ROOT, 'dist'); 318 + app.use(express.static(distPath)); 319 + 320 + // SPA fallback: serve the correct index.html for each sub-app 321 + // /docs/:id and /sheets/:id → editor pages 322 + app.get('/docs/:id', (_req: Request, res: Response) => res.sendFile(path.join(distPath, 'docs/index.html'))); 323 + app.get('/sheets/:id', (_req: Request, res: Response) => res.sendFile(path.join(distPath, 'sheets/index.html'))); 324 + // /docs, /sheets, / → landing page (document list + create) 325 + app.get('*', (_req: Request, res: Response) => res.sendFile(path.join(distPath, 'index.html'))); 326 + 327 + // --- HTTP + HTTPS + WebSocket server --- 328 + const server = createServer(app); 329 + 330 + // Self-signed HTTPS for crypto.subtle secure context (Tailscale encrypts transport) 331 + let httpsServer: HttpsServer | undefined; 332 + if (existsSync(TLS_CERT) && existsSync(TLS_KEY)) { 333 + try { 334 + httpsServer = createHttpsServer({ 335 + cert: readFileSync(TLS_CERT), 336 + key: readFileSync(TLS_KEY), 337 + }, app); 338 + console.log('HTTPS enabled with self-signed cert'); 339 + } catch (err: unknown) { 340 + const message = err instanceof Error ? err.message : 'Unknown error'; 341 + console.warn('Failed to load TLS cert, HTTPS disabled:', message); 342 + } 343 + } 344 + 345 + const wss = new WebSocketServer({ noServer: true }); 346 + 347 + // Room management for E2EE relay 348 + const rooms = new Map<string, Set<WebSocket>>(); 349 + 350 + function handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void { 351 + const url = new URL(request.url || '', 'http://localhost'); 352 + if (url.pathname !== '/ws') { 353 + socket.destroy(); 354 + return; 355 + } 356 + wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { 357 + const room = url.searchParams.get('room'); 358 + if (!room) { ws.close(); return; } 359 + 360 + if (!rooms.has(room)) rooms.set(room, new Set()); 361 + const peers = rooms.get(room)!; 362 + peers.add(ws); 363 + 364 + // Tell this client how many peers are connected (for sync) 365 + const msg: WsControlMessage = { type: 'peer-count', count: peers.size - 1 }; 366 + ws.send(JSON.stringify(msg)); 367 + 368 + // Notify others that a new peer joined 369 + for (const peer of peers) { 370 + if (peer !== ws && peer.readyState === 1) { 371 + const joinMsg: WsControlMessage = { type: 'peer-joined' }; 372 + peer.send(JSON.stringify(joinMsg)); 373 + } 374 + } 375 + 376 + ws.on('message', (data: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) => { 377 + // Relay encrypted messages to all other clients in the room 378 + for (const peer of peers) { 379 + if (peer !== ws && peer.readyState === 1) { 380 + peer.send(data, { binary: isBinary }); 381 + } 382 + } 383 + }); 384 + 385 + ws.on('close', () => { 386 + peers.delete(ws); 387 + if (peers.size === 0) { 388 + rooms.delete(room); 389 + } else { 390 + for (const peer of peers) { 391 + if (peer.readyState === 1) { 392 + const leftMsg: WsControlMessage = { type: 'peer-left', count: peers.size }; 393 + peer.send(JSON.stringify(leftMsg)); 394 + } 395 + } 396 + } 397 + }); 398 + }); 399 + } 400 + 401 + server.on('upgrade', handleUpgrade); 402 + if (httpsServer) httpsServer.on('upgrade', handleUpgrade); 403 + 404 + server.listen(PORT, () => { 405 + console.log(`Tools running on http://localhost:${PORT}`); 406 + }); 407 + 408 + if (httpsServer) { 409 + const HTTPS_PORT = process.env['HTTPS_PORT'] || 3443; 410 + httpsServer.listen(HTTPS_PORT, () => { 411 + console.log(`Tools HTTPS on https://localhost:${HTTPS_PORT}`); 412 + }); 413 + }
+4 -17
src/docs/autoformat-rules.js src/docs/autoformat-rules.ts
··· 9 9 * only the additional rules that are NOT built-in, plus exports a complete 10 10 * inventory of all active rules for documentation and testing. 11 11 */ 12 + import type { AutoformatRule, AutoformatMatch, ParsedLink } from './types.js'; 12 13 13 14 /** 14 15 * Regex for markdown-style links: [text](url) ··· 22 23 /** 23 24 * All active autoformat rules in the editor, including built-in ones. 24 25 * Used for testing and documentation purposes. 25 - * 26 - * Each entry describes: 27 - * - id: unique identifier 28 - * - description: what the rule does 29 - * - trigger: what the user types 30 - * - regex: the pattern that fires the rule 31 - * - source: which TipTap extension provides it 32 - * - custom: true if we implement it (false = built-in) 33 26 */ 34 - export const AUTOFORMAT_RULES = [ 27 + export const AUTOFORMAT_RULES: AutoformatRule[] = [ 35 28 // Block-level rules (built-in via StarterKit) 36 29 { 37 30 id: 'codeBlock', ··· 153 146 154 147 /** 155 148 * Determine which autoformat rule (if any) matches the given input text. 156 - * 157 - * @param {string} text - The text content at the current cursor position 158 - * @returns {{ id: string, match: RegExpMatchArray } | null} 159 149 */ 160 - export function resolveAutoformat(text) { 150 + export function resolveAutoformat(text: string): AutoformatMatch | null { 161 151 for (const rule of AUTOFORMAT_RULES) { 162 152 const match = text.match(rule.regex); 163 153 if (match) { ··· 169 159 170 160 /** 171 161 * Parse a markdown link match into its components. 172 - * 173 - * @param {RegExpMatchArray} match - The regex match from linkInputRegex 174 - * @returns {{ text: string, href: string }} 175 162 */ 176 - export function parseLinkMatch(match) { 163 + export function parseLinkMatch(match: RegExpMatchArray): ParsedLink { 177 164 return { 178 165 text: match[1], 179 166 href: match[2],
+18 -20
src/docs/block-handle.js src/docs/block-handle.ts
··· 4 4 * Pure logic module: handle state, context menu actions, and turn-into items. 5 5 * No DOM dependencies — rendering is handled in main.js. 6 6 */ 7 + import type { BlockHandlePosition, BlockHandleAction, TurnIntoItem, BlockHandleMode } from './types.js'; 7 8 8 9 // ============================================================ 9 10 // Icons ··· 19 20 // Context Menu Actions 20 21 // ============================================================ 21 22 22 - export const BLOCK_HANDLE_ACTIONS = [ 23 + export const BLOCK_HANDLE_ACTIONS: BlockHandleAction[] = [ 23 24 { id: 'turnInto', label: 'Turn into...', icon: '\u21C4' }, 24 25 { id: 'delete', label: 'Delete', icon: '\uD83D\uDDD1' }, 25 26 { id: 'duplicate', label: 'Duplicate', icon: '\u2398' }, ··· 31 32 // Turn Into Items (block type conversion targets) 32 33 // ============================================================ 33 34 34 - export const TURN_INTO_ITEMS = [ 35 + export const TURN_INTO_ITEMS: TurnIntoItem[] = [ 35 36 { id: 'paragraph', name: 'Paragraph', icon: '\u00B6' }, 36 37 { id: 'heading1', name: 'Heading 1', icon: 'H1' }, 37 38 { id: 'heading2', name: 'Heading 2', icon: 'H2' }, ··· 45 46 46 47 /** 47 48 * Filter turn-into items by a search query. 48 - * 49 - * @param {string} query 50 - * @returns {Array} 51 49 */ 52 - export function filterTurnIntoItems(query) { 50 + export function filterTurnIntoItems(query: string): TurnIntoItem[] { 53 51 const q = (query || '').trim().toLowerCase(); 54 52 if (!q) return [...TURN_INTO_ITEMS]; 55 53 return TURN_INTO_ITEMS.filter(item => item.name.toLowerCase().includes(q)); ··· 64 62 * Pure state object — no DOM coupling. 65 63 */ 66 64 export class BlockHandleState { 65 + visible: boolean; 66 + position: BlockHandlePosition | null; 67 + blockPos: number | null; 68 + contextMenuOpen: boolean; 69 + turnIntoMenuOpen: boolean; 70 + 67 71 constructor() { 68 72 this.visible = false; 69 73 this.position = null; ··· 75 79 /** 76 80 * Show the handle at a position, associated with a block at the given 77 81 * ProseMirror document position. 78 - * 79 - * @param {{top: number, left: number}} position - screen coordinates 80 - * @param {number} blockPos - ProseMirror node position 81 82 */ 82 - show(position, blockPos) { 83 + show(position: BlockHandlePosition, blockPos: number): void { 83 84 this.visible = true; 84 85 this.position = { ...position }; 85 86 this.blockPos = blockPos; 86 87 } 87 88 88 - hide() { 89 + hide(): void { 89 90 this.visible = false; 90 91 this.position = null; 91 92 this.blockPos = null; ··· 93 94 this.turnIntoMenuOpen = false; 94 95 } 95 96 96 - updatePosition(position) { 97 + updatePosition(position: BlockHandlePosition): void { 97 98 this.position = { ...position }; 98 99 } 99 100 100 - openContextMenu() { 101 + openContextMenu(): void { 101 102 this.contextMenuOpen = true; 102 103 } 103 104 104 - closeContextMenu() { 105 + closeContextMenu(): void { 105 106 this.contextMenuOpen = false; 106 107 this.turnIntoMenuOpen = false; 107 108 } 108 109 109 - openTurnIntoMenu() { 110 + openTurnIntoMenu(): void { 110 111 this.turnIntoMenuOpen = true; 111 112 } 112 113 113 - closeTurnIntoMenu() { 114 + closeTurnIntoMenu(): void { 114 115 this.turnIntoMenuOpen = false; 115 116 } 116 117 117 118 /** 118 119 * Determine if the handle should be hidden in a given mode. 119 - * 120 - * @param {'normal'|'zen'|'print'} mode 121 - * @returns {boolean} 122 120 */ 123 - isHiddenInMode(mode) { 121 + isHiddenInMode(mode: BlockHandleMode): boolean { 124 122 return mode === 'zen' || mode === 'print'; 125 123 } 126 124 }
+11 -15
src/docs/docx-import.js src/docs/docx-import.ts
··· 4 4 * Uses mammoth.js to convert .docx files to HTML, then feeds the result 5 5 * into the TipTap editor. 6 6 */ 7 + import type { Editor } from '@tiptap/core'; 8 + import type { DocxConvertResult, DocxMessage } from './types.js'; 7 9 8 10 /** 9 11 * Convert a .docx ArrayBuffer to HTML using mammoth.js. 10 12 * Pure async function — testable without DOM. 11 - * 12 - * @param {ArrayBuffer} arrayBuffer - The .docx file contents 13 - * @returns {Promise<{html: string, messages: Array}>} Converted HTML and any messages/warnings 14 13 */ 15 - export async function convertDocxToHtml(arrayBuffer) { 14 + export async function convertDocxToHtml(arrayBuffer: ArrayBuffer): Promise<DocxConvertResult> { 16 15 const mammoth = await import('mammoth'); 17 16 18 17 // mammoth accepts { arrayBuffer } in the browser and { buffer } in Node.js. ··· 34 33 const result = await mammoth.convertToHtml(input, options); 35 34 return { 36 35 html: result.value, 37 - messages: result.messages || [], 36 + messages: (result.messages || []) as DocxMessage[], 38 37 }; 39 38 } 40 39 41 40 /** 42 41 * Validate that the given ArrayBuffer looks like a valid .docx file. 43 42 * A .docx is a ZIP file, so it starts with the PK signature (0x504B0304). 44 - * 45 - * @param {ArrayBuffer} arrayBuffer 46 - * @returns {boolean} 47 43 */ 48 - export function isValidDocx(arrayBuffer) { 44 + export function isValidDocx(arrayBuffer: ArrayBuffer): boolean { 49 45 if (!arrayBuffer || arrayBuffer.byteLength < 4) return false; 50 46 const view = new Uint8Array(arrayBuffer); 51 47 return view[0] === 0x50 && view[1] === 0x4B && view[2] === 0x03 && view[3] === 0x04; ··· 54 50 /** 55 51 * Import a .docx File object into the TipTap editor. 56 52 * DOM-coupled entry point — not unit-testable. 57 - * 58 - * @param {File} file - The .docx file 59 - * @param {object} editor - TipTap editor instance 60 - * @param {function} showToast - Toast notification function 61 53 */ 62 - export async function importDocx(file, editor, showToast) { 54 + export async function importDocx( 55 + file: File, 56 + editor: Editor, 57 + showToast: (message: string, duration: number) => void, 58 + ): Promise<void> { 63 59 try { 64 60 const arrayBuffer = await file.arrayBuffer(); 65 61 ··· 77 73 78 74 editor.commands.setContent(html); 79 75 80 - const warnings = messages.filter(m => m.type === 'warning'); 76 + const warnings = messages.filter((m: DocxMessage) => m.type === 'warning'); 81 77 if (warnings.length > 0) { 82 78 showToast(`Imported "${file.name}" with ${warnings.length} warning(s)`, 4000); 83 79 } else {
-70
src/docs/extensions/comment.js
··· 1 - import { Mark, mergeAttributes } from '@tiptap/core'; 2 - 3 - /** 4 - * Comment mark — stores inline comments as marks on text ranges. 5 - * Each comment has: id, author, timestamp, text. 6 - * Rendered as highlighted spans with data attributes for popover display. 7 - */ 8 - export const Comment = Mark.create({ 9 - name: 'comment', 10 - 11 - addOptions() { 12 - return { 13 - HTMLAttributes: {}, 14 - }; 15 - }, 16 - 17 - addAttributes() { 18 - return { 19 - commentId: { 20 - default: null, 21 - parseHTML: (el) => el.getAttribute('data-comment-id'), 22 - renderHTML: (attrs) => ({ 'data-comment-id': attrs.commentId }), 23 - }, 24 - author: { 25 - default: null, 26 - parseHTML: (el) => el.getAttribute('data-comment-author'), 27 - renderHTML: (attrs) => ({ 'data-comment-author': attrs.author }), 28 - }, 29 - timestamp: { 30 - default: null, 31 - parseHTML: (el) => el.getAttribute('data-comment-timestamp'), 32 - renderHTML: (attrs) => ({ 'data-comment-timestamp': attrs.timestamp }), 33 - }, 34 - text: { 35 - default: null, 36 - parseHTML: (el) => el.getAttribute('data-comment-text'), 37 - renderHTML: (attrs) => ({ 'data-comment-text': attrs.text }), 38 - }, 39 - }; 40 - }, 41 - 42 - parseHTML() { 43 - return [ 44 - { 45 - tag: 'span[data-comment-id]', 46 - }, 47 - ]; 48 - }, 49 - 50 - renderHTML({ HTMLAttributes }) { 51 - return [ 52 - 'span', 53 - mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 54 - class: 'comment-mark', 55 - }), 56 - 0, 57 - ]; 58 - }, 59 - 60 - addCommands() { 61 - return { 62 - setComment: (attrs) => ({ commands }) => { 63 - return commands.setMark(this.name, attrs); 64 - }, 65 - unsetComment: () => ({ commands }) => { 66 - return commands.unsetMark(this.name); 67 - }, 68 - }; 69 - }, 70 - });
+75
src/docs/extensions/comment.ts
··· 1 + import { Mark, mergeAttributes } from '@tiptap/core'; 2 + import type { CommentAttrs } from '../types.js'; 3 + 4 + interface CommentOptions { 5 + HTMLAttributes: Record<string, string>; 6 + } 7 + 8 + /** 9 + * Comment mark — stores inline comments as marks on text ranges. 10 + * Each comment has: id, author, timestamp, text. 11 + * Rendered as highlighted spans with data attributes for popover display. 12 + */ 13 + export const Comment = Mark.create<CommentOptions>({ 14 + name: 'comment', 15 + 16 + addOptions() { 17 + return { 18 + HTMLAttributes: {}, 19 + }; 20 + }, 21 + 22 + addAttributes() { 23 + return { 24 + commentId: { 25 + default: null, 26 + parseHTML: (el: HTMLElement) => el.getAttribute('data-comment-id'), 27 + renderHTML: (attrs: Record<string, string | null>) => ({ 'data-comment-id': attrs.commentId }), 28 + }, 29 + author: { 30 + default: null, 31 + parseHTML: (el: HTMLElement) => el.getAttribute('data-comment-author'), 32 + renderHTML: (attrs: Record<string, string | null>) => ({ 'data-comment-author': attrs.author }), 33 + }, 34 + timestamp: { 35 + default: null, 36 + parseHTML: (el: HTMLElement) => el.getAttribute('data-comment-timestamp'), 37 + renderHTML: (attrs: Record<string, string | null>) => ({ 'data-comment-timestamp': attrs.timestamp }), 38 + }, 39 + text: { 40 + default: null, 41 + parseHTML: (el: HTMLElement) => el.getAttribute('data-comment-text'), 42 + renderHTML: (attrs: Record<string, string | null>) => ({ 'data-comment-text': attrs.text }), 43 + }, 44 + }; 45 + }, 46 + 47 + parseHTML() { 48 + return [ 49 + { 50 + tag: 'span[data-comment-id]', 51 + }, 52 + ]; 53 + }, 54 + 55 + renderHTML({ HTMLAttributes }) { 56 + return [ 57 + 'span', 58 + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 59 + class: 'comment-mark', 60 + }), 61 + 0, 62 + ]; 63 + }, 64 + 65 + addCommands() { 66 + return { 67 + setComment: (attrs: CommentAttrs) => ({ commands }) => { 68 + return commands.setMark(this.name, attrs); 69 + }, 70 + unsetComment: () => ({ commands }) => { 71 + return commands.unsetMark(this.name); 72 + }, 73 + }; 74 + }, 75 + });
+8 -4
src/docs/extensions/font-size.js src/docs/extensions/font-size.ts
··· 1 1 import { Extension } from '@tiptap/core'; 2 2 3 + interface FontSizeOptions { 4 + types: string[]; 5 + } 6 + 3 7 /** 4 8 * FontSize extension — adds fontSize attribute to TextStyle marks. 5 9 * Usage: editor.chain().focus().setFontSize('16px').run() 6 10 * editor.chain().focus().unsetFontSize().run() 7 11 */ 8 - export const FontSize = Extension.create({ 12 + export const FontSize = Extension.create<FontSizeOptions>({ 9 13 name: 'fontSize', 10 14 11 15 addOptions() { ··· 21 25 attributes: { 22 26 fontSize: { 23 27 default: null, 24 - parseHTML: (element) => element.style.fontSize?.replace(/['"]+/g, '') || null, 25 - renderHTML: (attributes) => { 28 + parseHTML: (element: HTMLElement) => element.style.fontSize?.replace(/['"]+/g, '') || null, 29 + renderHTML: (attributes: Record<string, string | null>) => { 26 30 if (!attributes.fontSize) return {}; 27 31 return { style: `font-size: ${attributes.fontSize}` }; 28 32 }, ··· 34 38 35 39 addCommands() { 36 40 return { 37 - setFontSize: (fontSize) => ({ chain }) => { 41 + setFontSize: (fontSize: string) => ({ chain }) => { 38 42 return chain().setMark('textStyle', { fontSize }).run(); 39 43 }, 40 44 unsetFontSize: () => ({ chain }) => {
+11 -5
src/docs/extensions/indent.js src/docs/extensions/indent.ts
··· 1 1 import { Extension } from '@tiptap/core'; 2 2 3 + interface IndentOptions { 4 + types: string[]; 5 + step: number; 6 + maxLevel: number; 7 + } 8 + 3 9 /** 4 10 * Indent extension — adds indentation (margin-left) to paragraphs and headings. 5 11 * Steps in increments of 2rem. Min 0, max 10 levels. ··· 7 13 * Keyboard shortcuts: Cmd+] (indent), Cmd+[ (outdent) dispatch to the 8 14 * appropriate command based on context (list vs paragraph). 9 15 */ 10 - export const Indent = Extension.create({ 16 + export const Indent = Extension.create<IndentOptions>({ 11 17 name: 'indent', 12 18 13 19 addOptions() { ··· 25 31 attributes: { 26 32 indent: { 27 33 default: 0, 28 - parseHTML: (element) => { 34 + parseHTML: (element: HTMLElement) => { 29 35 const ml = element.style.marginLeft; 30 36 if (!ml) return 0; 31 37 const val = parseFloat(ml); 32 38 return Math.round(val / this.options.step); 33 39 }, 34 - renderHTML: (attributes) => { 40 + renderHTML: (attributes: Record<string, number>) => { 35 41 if (!attributes.indent || attributes.indent <= 0) return {}; 36 42 return { 37 43 style: `margin-left: ${attributes.indent * this.options.step}rem`, ··· 50 56 let changed = false; 51 57 state.doc.nodesBetween(from, to, (node, pos) => { 52 58 if (this.options.types.includes(node.type.name)) { 53 - const current = node.attrs.indent || 0; 59 + const current = (node.attrs.indent as number) || 0; 54 60 if (current < this.options.maxLevel) { 55 61 if (dispatch) { 56 62 tr.setNodeMarkup(pos, undefined, { ··· 69 75 let changed = false; 70 76 state.doc.nodesBetween(from, to, (node, pos) => { 71 77 if (this.options.types.includes(node.type.name)) { 72 - const current = node.attrs.indent || 0; 78 + const current = (node.attrs.indent as number) || 0; 73 79 if (current > 0) { 74 80 if (dispatch) { 75 81 tr.setNodeMarkup(pos, undefined, {
+9 -5
src/docs/extensions/line-spacing.js src/docs/extensions/line-spacing.ts
··· 1 1 import { Extension } from '@tiptap/core'; 2 2 3 + interface LineSpacingOptions { 4 + types: string[]; 5 + } 6 + 3 7 /** 4 8 * LineSpacing extension — adds lineHeight attribute to paragraph and heading nodes. 5 9 * ··· 10 14 * editor.chain().focus().setLineSpacing('1.5').run() 11 15 * editor.chain().focus().unsetLineSpacing().run() 12 16 */ 13 - export const LINE_SPACING_PRESETS = ['1', '1.15', '1.5', '2', '2.5', '3']; 17 + export const LINE_SPACING_PRESETS: string[] = ['1', '1.15', '1.5', '2', '2.5', '3']; 14 18 15 - export const LineSpacing = Extension.create({ 19 + export const LineSpacing = Extension.create<LineSpacingOptions>({ 16 20 name: 'lineSpacing', 17 21 18 22 addOptions() { ··· 28 32 attributes: { 29 33 lineHeight: { 30 34 default: null, 31 - parseHTML: (element) => { 35 + parseHTML: (element: HTMLElement) => { 32 36 const lh = element.style.lineHeight; 33 37 if (!lh) return null; 34 38 const num = parseFloat(lh); 35 39 if (isNaN(num)) return null; 36 40 return String(num); 37 41 }, 38 - renderHTML: (attributes) => { 42 + renderHTML: (attributes: Record<string, string | null>) => { 39 43 if (!attributes.lineHeight) return {}; 40 44 return { style: `line-height: ${attributes.lineHeight}` }; 41 45 }, ··· 47 51 48 52 addCommands() { 49 53 return { 50 - setLineSpacing: (lineHeight) => ({ tr, state, dispatch }) => { 54 + setLineSpacing: (lineHeight: string) => ({ tr, state, dispatch }) => { 51 55 const { from, to } = state.selection; 52 56 let changed = false; 53 57 state.doc.nodesBetween(from, to, (node, pos) => {
+1 -1
src/docs/extensions/markdown-autoformat.js src/docs/extensions/markdown-autoformat.ts
··· 17 17 name: 'markdownAutoformat', 18 18 19 19 addInputRules() { 20 - const rules = []; 20 + const rules: InputRule[] = []; 21 21 22 22 // [text](url) → Link 23 23 // When user types [link text](https://example.com), replace with a
src/docs/extensions/page-break.js src/docs/extensions/page-break.ts
+9 -5
src/docs/extensions/paragraph-spacing.js src/docs/extensions/paragraph-spacing.ts
··· 1 1 import { Extension } from '@tiptap/core'; 2 2 3 + interface ParagraphSpacingOptions { 4 + types: string[]; 5 + } 6 + 3 7 /** 4 8 * ParagraphSpacing extension — adds paragraphSpacing attribute to paragraph 5 9 * and heading nodes as margin-bottom. ··· 11 15 * editor.chain().focus().setParagraphSpacing('small').run() 12 16 * editor.chain().focus().unsetParagraphSpacing().run() 13 17 */ 14 - export const PARAGRAPH_SPACING_PRESETS = { 18 + export const PARAGRAPH_SPACING_PRESETS: Record<string, string> = { 15 19 none: '0', 16 20 small: '0.5rem', 17 21 medium: '1rem', 18 22 large: '1.5rem', 19 23 }; 20 24 21 - export const ParagraphSpacing = Extension.create({ 25 + export const ParagraphSpacing = Extension.create<ParagraphSpacingOptions>({ 22 26 name: 'paragraphSpacing', 23 27 24 28 addOptions() { ··· 34 38 attributes: { 35 39 paragraphSpacing: { 36 40 default: null, 37 - parseHTML: (element) => { 41 + parseHTML: (element: HTMLElement) => { 38 42 const mb = element.style.marginBottom; 39 43 if (!mb && mb !== '0' && mb !== '0px' && mb !== '0rem') return null; 40 44 if (mb === '0' || mb === '0px' || mb === '0rem') return 'none'; ··· 43 47 if (mb === '1.5rem') return 'large'; 44 48 return null; 45 49 }, 46 - renderHTML: (attributes) => { 50 + renderHTML: (attributes: Record<string, string | null>) => { 47 51 if (!attributes.paragraphSpacing) return {}; 48 52 const value = PARAGRAPH_SPACING_PRESETS[attributes.paragraphSpacing]; 49 53 if (value === undefined) return {}; ··· 57 61 58 62 addCommands() { 59 63 return { 60 - setParagraphSpacing: (spacing) => ({ tr, state, dispatch }) => { 64 + setParagraphSpacing: (spacing: string) => ({ tr, state, dispatch }) => { 61 65 const { from, to } = state.selection; 62 66 let changed = false; 63 67 state.doc.nodesBetween(from, to, (node, pos) => {
+30 -35
src/docs/extensions/slash-commands.js src/docs/extensions/slash-commands.ts
··· 10 10 */ 11 11 12 12 import { Extension } from '@tiptap/core'; 13 + import type { Editor } from '@tiptap/core'; 13 14 import Suggestion from '@tiptap/suggestion'; 15 + import type { SlashCommandsConfig, SlashCommandExecutableItem, CommandExecutor, SuggestionCallbackProps } from '../types.js'; 16 + 17 + interface SlashCommandItemWithId { 18 + id: string; 19 + } 14 20 15 21 /** 16 22 * Create the slash commands extension. 17 - * 18 - * @param {object} opts 19 - * @param {function} opts.onStart - Called when "/" is typed. Receives { query, clientRect, command } 20 - * @param {function} opts.onUpdate - Called as user types after "/". Same args. 21 - * @param {function} opts.onExit - Called when the menu should close. 22 - * @param {function} opts.onKeyDown - Called on keydown events while menu is open. Return true to prevent default. 23 - * @param {function} opts.items - Function(query) returning filtered command items. 24 - * @returns {Extension} 25 23 */ 26 - export function createSlashCommands({ onStart, onUpdate, onExit, onKeyDown, items }) { 24 + export function createSlashCommands({ onStart, onUpdate, onExit, onKeyDown, items }: SlashCommandsConfig) { 27 25 return Extension.create({ 28 26 name: 'slashCommands', 29 27 ··· 32 30 suggestion: { 33 31 char: '/', 34 32 startOfLine: false, 35 - command: ({ editor, range, props }) => { 33 + command: ({ editor, range, props }: { editor: Editor; range: { from: number; to: number }; props: SlashCommandExecutableItem }) => { 36 34 // Delete the "/" trigger text 37 35 editor.chain().focus().deleteRange(range).run(); 38 36 ··· 41 39 props.execute(editor); 42 40 } 43 41 }, 44 - items: ({ query }) => { 42 + items: ({ query }: { query: string }) => { 45 43 return items(query); 46 44 }, 47 45 render: () => ({ 48 - onStart: (props) => { 46 + onStart: (props: SuggestionCallbackProps) => { 49 47 if (onStart) onStart(props); 50 48 }, 51 - onUpdate: (props) => { 49 + onUpdate: (props: SuggestionCallbackProps) => { 52 50 if (onUpdate) onUpdate(props); 53 51 }, 54 52 onExit: () => { 55 53 if (onExit) onExit(); 56 54 }, 57 - onKeyDown: (props) => { 55 + onKeyDown: (props: { event: KeyboardEvent }) => { 58 56 if (onKeyDown) return onKeyDown(props); 59 57 return false; 60 58 }, ··· 77 75 /** 78 76 * Maps a slash command item id to TipTap editor commands. 79 77 * Returns an execute function that applies the block type. 80 - * 81 - * @param {object} item - A slash command item from SLASH_COMMAND_ITEMS 82 - * @returns {function} execute(editor) - Function that applies the command 83 78 */ 84 - export function getCommandExecutor(item) { 85 - const executors = { 86 - paragraph: (editor) => { 79 + export function getCommandExecutor(item: SlashCommandItemWithId): CommandExecutor { 80 + const executors: Record<string, CommandExecutor> = { 81 + paragraph: (editor: Editor) => { 87 82 editor.chain().focus().setParagraph().run(); 88 83 }, 89 - heading1: (editor) => { 84 + heading1: (editor: Editor) => { 90 85 editor.chain().focus().toggleHeading({ level: 1 }).run(); 91 86 }, 92 - heading2: (editor) => { 87 + heading2: (editor: Editor) => { 93 88 editor.chain().focus().toggleHeading({ level: 2 }).run(); 94 89 }, 95 - heading3: (editor) => { 90 + heading3: (editor: Editor) => { 96 91 editor.chain().focus().toggleHeading({ level: 3 }).run(); 97 92 }, 98 - bulletList: (editor) => { 93 + bulletList: (editor: Editor) => { 99 94 editor.chain().focus().toggleBulletList().run(); 100 95 }, 101 - numberedList: (editor) => { 96 + numberedList: (editor: Editor) => { 102 97 editor.chain().focus().toggleOrderedList().run(); 103 98 }, 104 - taskList: (editor) => { 99 + taskList: (editor: Editor) => { 105 100 editor.chain().focus().toggleTaskList().run(); 106 101 }, 107 - image: (editor) => { 102 + image: (editor: Editor) => { 108 103 const url = prompt('Image URL:'); 109 104 if (url) { 110 105 editor.chain().focus().setImage({ src: url }).run(); 111 106 } 112 107 }, 113 - table: (editor) => { 108 + table: (editor: Editor) => { 114 109 editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); 115 110 }, 116 - horizontalRule: (editor) => { 111 + horizontalRule: (editor: Editor) => { 117 112 editor.chain().focus().setHorizontalRule().run(); 118 113 }, 119 - codeBlock: (editor) => { 114 + codeBlock: (editor: Editor) => { 120 115 editor.chain().focus().toggleCodeBlock().run(); 121 116 }, 122 - inlineCode: (editor) => { 117 + inlineCode: (editor: Editor) => { 123 118 editor.chain().focus().toggleCode().run(); 124 119 }, 125 - blockquote: (editor) => { 120 + blockquote: (editor: Editor) => { 126 121 editor.chain().focus().toggleBlockquote().run(); 127 122 }, 128 - callout: (editor) => { 123 + callout: (editor: Editor) => { 129 124 // Callout is a blockquote with special styling 130 125 editor.chain().focus().toggleBlockquote().run(); 131 126 }, 132 - pageBreak: (editor) => { 127 + pageBreak: (editor: Editor) => { 133 128 editor.chain().focus().insertPageBreak().run(); 134 129 }, 135 - link: (editor) => { 130 + link: (editor: Editor) => { 136 131 const url = prompt('Link URL:'); 137 132 if (url) { 138 133 editor.chain().focus().setLink({ href: url }).run();
-64
src/docs/extensions/suggestion-delete.js
··· 1 - import { Mark, mergeAttributes } from '@tiptap/core'; 2 - 3 - /** 4 - * Suggestion Delete mark — tracks suggested deletions. 5 - * Rendered as strikethrough text with author color. 6 - */ 7 - export const SuggestionDelete = Mark.create({ 8 - name: 'suggestionDelete', 9 - 10 - addOptions() { 11 - return { 12 - HTMLAttributes: {}, 13 - }; 14 - }, 15 - 16 - addAttributes() { 17 - return { 18 - suggestionId: { 19 - default: null, 20 - parseHTML: (el) => el.getAttribute('data-suggestion-id'), 21 - renderHTML: (attrs) => ({ 'data-suggestion-id': attrs.suggestionId }), 22 - }, 23 - author: { 24 - default: null, 25 - parseHTML: (el) => el.getAttribute('data-suggestion-author'), 26 - renderHTML: (attrs) => ({ 'data-suggestion-author': attrs.author }), 27 - }, 28 - timestamp: { 29 - default: null, 30 - parseHTML: (el) => el.getAttribute('data-suggestion-timestamp'), 31 - renderHTML: (attrs) => ({ 'data-suggestion-timestamp': attrs.timestamp }), 32 - }, 33 - }; 34 - }, 35 - 36 - parseHTML() { 37 - return [ 38 - { tag: 'span[data-suggestion-id][data-suggestion-type="delete"]' }, 39 - { tag: 'span.suggestion-delete' }, 40 - ]; 41 - }, 42 - 43 - renderHTML({ HTMLAttributes }) { 44 - return [ 45 - 'span', 46 - mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 47 - class: 'suggestion-delete', 48 - 'data-suggestion-type': 'delete', 49 - }), 50 - 0, 51 - ]; 52 - }, 53 - 54 - addCommands() { 55 - return { 56 - setSuggestionDelete: (attrs) => ({ commands }) => { 57 - return commands.setMark(this.name, attrs); 58 - }, 59 - unsetSuggestionDelete: () => ({ commands }) => { 60 - return commands.unsetMark(this.name); 61 - }, 62 - }; 63 - }, 64 - });
+69
src/docs/extensions/suggestion-delete.ts
··· 1 + import { Mark, mergeAttributes } from '@tiptap/core'; 2 + import type { SuggestionMarkAttrs } from '../types.js'; 3 + 4 + interface SuggestionDeleteOptions { 5 + HTMLAttributes: Record<string, string>; 6 + } 7 + 8 + /** 9 + * Suggestion Delete mark — tracks suggested deletions. 10 + * Rendered as strikethrough text with author color. 11 + */ 12 + export const SuggestionDelete = Mark.create<SuggestionDeleteOptions>({ 13 + name: 'suggestionDelete', 14 + 15 + addOptions() { 16 + return { 17 + HTMLAttributes: {}, 18 + }; 19 + }, 20 + 21 + addAttributes() { 22 + return { 23 + suggestionId: { 24 + default: null, 25 + parseHTML: (el: HTMLElement) => el.getAttribute('data-suggestion-id'), 26 + renderHTML: (attrs: Record<string, string | null>) => ({ 'data-suggestion-id': attrs.suggestionId }), 27 + }, 28 + author: { 29 + default: null, 30 + parseHTML: (el: HTMLElement) => el.getAttribute('data-suggestion-author'), 31 + renderHTML: (attrs: Record<string, string | null>) => ({ 'data-suggestion-author': attrs.author }), 32 + }, 33 + timestamp: { 34 + default: null, 35 + parseHTML: (el: HTMLElement) => el.getAttribute('data-suggestion-timestamp'), 36 + renderHTML: (attrs: Record<string, string | null>) => ({ 'data-suggestion-timestamp': attrs.timestamp }), 37 + }, 38 + }; 39 + }, 40 + 41 + parseHTML() { 42 + return [ 43 + { tag: 'span[data-suggestion-id][data-suggestion-type="delete"]' }, 44 + { tag: 'span.suggestion-delete' }, 45 + ]; 46 + }, 47 + 48 + renderHTML({ HTMLAttributes }) { 49 + return [ 50 + 'span', 51 + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 52 + class: 'suggestion-delete', 53 + 'data-suggestion-type': 'delete', 54 + }), 55 + 0, 56 + ]; 57 + }, 58 + 59 + addCommands() { 60 + return { 61 + setSuggestionDelete: (attrs: SuggestionMarkAttrs) => ({ commands }) => { 62 + return commands.setMark(this.name, attrs); 63 + }, 64 + unsetSuggestionDelete: () => ({ commands }) => { 65 + return commands.unsetMark(this.name); 66 + }, 67 + }; 68 + }, 69 + });
-64
src/docs/extensions/suggestion-insert.js
··· 1 - import { Mark, mergeAttributes } from '@tiptap/core'; 2 - 3 - /** 4 - * Suggestion Insert mark — tracks suggested insertions. 5 - * Rendered as underlined text with author color. 6 - */ 7 - export const SuggestionInsert = Mark.create({ 8 - name: 'suggestionInsert', 9 - 10 - addOptions() { 11 - return { 12 - HTMLAttributes: {}, 13 - }; 14 - }, 15 - 16 - addAttributes() { 17 - return { 18 - suggestionId: { 19 - default: null, 20 - parseHTML: (el) => el.getAttribute('data-suggestion-id'), 21 - renderHTML: (attrs) => ({ 'data-suggestion-id': attrs.suggestionId }), 22 - }, 23 - author: { 24 - default: null, 25 - parseHTML: (el) => el.getAttribute('data-suggestion-author'), 26 - renderHTML: (attrs) => ({ 'data-suggestion-author': attrs.author }), 27 - }, 28 - timestamp: { 29 - default: null, 30 - parseHTML: (el) => el.getAttribute('data-suggestion-timestamp'), 31 - renderHTML: (attrs) => ({ 'data-suggestion-timestamp': attrs.timestamp }), 32 - }, 33 - }; 34 - }, 35 - 36 - parseHTML() { 37 - return [ 38 - { tag: 'span[data-suggestion-id][data-suggestion-type="insert"]' }, 39 - { tag: 'span.suggestion-insert' }, 40 - ]; 41 - }, 42 - 43 - renderHTML({ HTMLAttributes }) { 44 - return [ 45 - 'span', 46 - mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 47 - class: 'suggestion-insert', 48 - 'data-suggestion-type': 'insert', 49 - }), 50 - 0, 51 - ]; 52 - }, 53 - 54 - addCommands() { 55 - return { 56 - setSuggestionInsert: (attrs) => ({ commands }) => { 57 - return commands.setMark(this.name, attrs); 58 - }, 59 - unsetSuggestionInsert: () => ({ commands }) => { 60 - return commands.unsetMark(this.name); 61 - }, 62 - }; 63 - }, 64 - });
+69
src/docs/extensions/suggestion-insert.ts
··· 1 + import { Mark, mergeAttributes } from '@tiptap/core'; 2 + import type { SuggestionMarkAttrs } from '../types.js'; 3 + 4 + interface SuggestionInsertOptions { 5 + HTMLAttributes: Record<string, string>; 6 + } 7 + 8 + /** 9 + * Suggestion Insert mark — tracks suggested insertions. 10 + * Rendered as underlined text with author color. 11 + */ 12 + export const SuggestionInsert = Mark.create<SuggestionInsertOptions>({ 13 + name: 'suggestionInsert', 14 + 15 + addOptions() { 16 + return { 17 + HTMLAttributes: {}, 18 + }; 19 + }, 20 + 21 + addAttributes() { 22 + return { 23 + suggestionId: { 24 + default: null, 25 + parseHTML: (el: HTMLElement) => el.getAttribute('data-suggestion-id'), 26 + renderHTML: (attrs: Record<string, string | null>) => ({ 'data-suggestion-id': attrs.suggestionId }), 27 + }, 28 + author: { 29 + default: null, 30 + parseHTML: (el: HTMLElement) => el.getAttribute('data-suggestion-author'), 31 + renderHTML: (attrs: Record<string, string | null>) => ({ 'data-suggestion-author': attrs.author }), 32 + }, 33 + timestamp: { 34 + default: null, 35 + parseHTML: (el: HTMLElement) => el.getAttribute('data-suggestion-timestamp'), 36 + renderHTML: (attrs: Record<string, string | null>) => ({ 'data-suggestion-timestamp': attrs.timestamp }), 37 + }, 38 + }; 39 + }, 40 + 41 + parseHTML() { 42 + return [ 43 + { tag: 'span[data-suggestion-id][data-suggestion-type="insert"]' }, 44 + { tag: 'span.suggestion-insert' }, 45 + ]; 46 + }, 47 + 48 + renderHTML({ HTMLAttributes }) { 49 + return [ 50 + 'span', 51 + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 52 + class: 'suggestion-insert', 53 + 'data-suggestion-type': 'insert', 54 + }), 55 + 0, 56 + ]; 57 + }, 58 + 59 + addCommands() { 60 + return { 61 + setSuggestionInsert: (attrs: SuggestionMarkAttrs) => ({ commands }) => { 62 + return commands.setMark(this.name, attrs); 63 + }, 64 + unsetSuggestionInsert: () => ({ commands }) => { 65 + return commands.unsetMark(this.name); 66 + }, 67 + }; 68 + }, 69 + });
+30
src/docs/html2pdf.d.ts
··· 1 + /** 2 + * Type declarations for html2pdf.js 3 + */ 4 + 5 + interface Html2PdfOptions { 6 + margin?: number | number[]; 7 + filename?: string; 8 + image?: { type: string; quality: number }; 9 + html2canvas?: { 10 + scale?: number; 11 + useCORS?: boolean; 12 + backgroundColor?: string; 13 + }; 14 + jsPDF?: { 15 + unit?: string; 16 + format?: string; 17 + orientation?: string; 18 + }; 19 + } 20 + 21 + interface Html2PdfInstance { 22 + set(options: Html2PdfOptions): Html2PdfInstance; 23 + from(element: HTMLElement): Html2PdfInstance; 24 + save(): Promise<void>; 25 + } 26 + 27 + declare module 'html2pdf.js' { 28 + function html2pdf(): Html2PdfInstance; 29 + export default html2pdf; 30 + }
+1 -1
src/docs/index.html
··· 399 399 </div> 400 400 </div> 401 401 402 - <script type="module" src="./main.js"></script> 402 + <script type="module" src="./main.ts"></script> 403 403 <script> 404 404 // Service Worker registration for offline support 405 405 if ('serviceWorker' in navigator) {
+29 -22
src/docs/main.js src/docs/main.ts
··· 1 + // Extend Window interface for import-in-progress flag 2 + declare global { 3 + interface Window { 4 + __importInProgress?: boolean; 5 + } 6 + } 7 + 1 8 import * as Y from 'yjs'; 2 9 import { Editor } from '@tiptap/core'; 3 10 import StarterKit from '@tiptap/starter-kit'; ··· 189 196 190 197 // --- Slash Command Menu --- 191 198 const slashMenuState = new SlashMenuState(); 192 - let slashMenuCommandRef = null; 199 + let slashMenuCommandRef: ((item: Record<string, unknown>) => void) | null = null; 193 200 194 201 // Create the slash menu popup element 195 202 const slashMenuEl = document.createElement('div'); ··· 198 205 slashMenuEl.style.display = 'none'; 199 206 document.body.appendChild(slashMenuEl); 200 207 201 - function renderSlashMenu(props) { 208 + function renderSlashMenu(props: { command: (item: Record<string, unknown>) => void; query?: string; clientRect?: (() => DOMRect | null) | null }): void { 202 209 slashMenuCommandRef = props.command; 203 210 const items = slashMenuState.getFilteredItems(); 204 211 const grouped = slashMenuState.getGroupedItems(); ··· 251 258 }); 252 259 } 253 260 254 - function positionSlashMenu(props) { 261 + function positionSlashMenu(props: { clientRect?: (() => DOMRect | null) | null }): void { 255 262 if (!props.clientRect) return; 256 263 const rect = props.clientRect(); 257 264 if (!rect) return; ··· 296 303 297 304 // Block handle: show on hover near editor blocks 298 305 const editorEl = document.getElementById('editor'); 299 - let blockHandleTimeout = null; 306 + let blockHandleTimeout: ReturnType<typeof setTimeout> | null = null; 300 307 301 - function showBlockHandle(blockElement, pos) { 308 + function showBlockHandle(blockElement: Element, pos: number): void { 302 309 if (!blockElement) return; 303 310 const editorRect = editorEl.getBoundingClientRect(); 304 311 const blockRect = blockElement.getBoundingClientRect(); ··· 343 350 }); 344 351 } 345 352 346 - function executeBlockAction(actionId) { 353 + function executeBlockAction(actionId: string): void { 347 354 const pos = blockHandleState.blockPos; 348 355 if (pos == null) return; 349 356 ··· 393 400 }); 394 401 } 395 402 396 - function executeTurnInto(typeId) { 403 + function executeTurnInto(typeId: string): void { 397 404 const executor = getCommandExecutor({ id: typeId }); 398 405 if (executor) { 399 406 executor(editor); ··· 475 482 }); 476 483 477 484 // --- Toolbar wiring --- 478 - const $ = (id) => document.getElementById(id); 485 + const $ = (id: string): HTMLElement => document.getElementById(id) as HTMLElement; 479 486 480 487 // --- Dropdown/overflow menu utilities --- 481 488 function closeAllDropdowns() { ··· 709 716 const commentAuthorEl = $('comment-author'); 710 717 const commentTimeEl = $('comment-time'); 711 718 const commentTextEl = $('comment-text'); 712 - let activeCommentMark = null; 719 + let activeCommentMark: { id: string; element: Element } | null = null; 713 720 714 721 document.addEventListener('click', (e) => { 715 722 const commentEl = e.target.closest('.comment-mark'); ··· 895 902 } 896 903 loadTitle(); 897 904 898 - let titleSaveTimeout; 905 + let titleSaveTimeout: ReturnType<typeof setTimeout>; 899 906 titleInput.addEventListener('input', () => { 900 907 clearTimeout(titleSaveTimeout); 901 908 titleSaveTimeout = setTimeout(async () => { ··· 947 954 }); 948 955 949 956 // --- Download helper --- 950 - function downloadFile(content, filename, mimeType) { 957 + function downloadFile(content: string, filename: string, mimeType: string): void { 951 958 const blob = new Blob([content], { type: mimeType }); 952 959 const url = URL.createObjectURL(blob); 953 960 const a = document.createElement('a'); ··· 987 994 } 988 995 989 996 // --- Toast notification --- 990 - function showToast(message, duration = 3000) { 997 + function showToast(message: string, duration = 3000): void { 991 998 const existing = document.querySelector('.toast-notification'); 992 999 if (existing) existing.remove(); 993 1000 const toast = document.createElement('div'); ··· 1008 1015 } 1009 1016 1010 1017 // --- Import functions --- 1011 - function handleImportedFile(file) { 1018 + function handleImportedFile(file: File): void { 1012 1019 const ext = file.name.split('.').pop().toLowerCase(); 1013 1020 1014 1021 // Handle .docx files via mammoth ··· 1074 1081 let lastSaveTime = Date.now(); 1075 1082 let saveState = 'saved'; 1076 1083 1077 - function setSaveState(state, time) { 1084 + function setSaveState(state: string, time?: number): void { 1078 1085 saveState = state; 1079 1086 saveIndicator.classList.remove('saved', 'saving', 'unsaved'); 1080 1087 saveIndicator.classList.add(state); ··· 1191 1198 ]}, 1192 1199 ]; 1193 1200 1194 - function buildShortcutModal(shortcuts) { 1201 + function buildShortcutModal(shortcuts: Array<{ category: string; shortcuts: Array<{ keys: string[]; label: string }> }>): HTMLElement { 1195 1202 const overlay = document.createElement('div'); 1196 1203 overlay.className = 'modal-overlay'; 1197 1204 const modal = document.createElement('div'); ··· 1445 1452 const versionList = $('version-list'); 1446 1453 const versionPreview = $('version-preview'); 1447 1454 const versionPreviewContent = $('version-preview-content'); 1448 - let selectedVersionId = null; 1455 + let selectedVersionId: string | null = null; 1449 1456 1450 1457 // Track edits for version capture triggers 1451 1458 editor.on('update', () => { ··· 1551 1558 } 1552 1559 } 1553 1560 1554 - async function showVersionPreview(versionId) { 1561 + async function showVersionPreview(versionId: string): Promise<void> { 1555 1562 selectedVersionId = versionId; 1556 1563 versionPreview.style.display = ''; 1557 1564 versionPreviewContent.textContent = 'Loading...'; ··· 1612 1619 const suggestionAuthorEl = $('suggestion-author'); 1613 1620 const suggestionTimeEl = $('suggestion-time'); 1614 1621 const suggestionTypeEl = $('suggestion-type'); 1615 - let activeSuggestion = null; 1622 + let activeSuggestion: { id: string; element: Element; type: 'insert' | 'delete' } | null = null; 1616 1623 1617 1624 suggestingBtn.addEventListener('click', () => { 1618 1625 suggestionMgr.toggleMode(); ··· 1654 1661 } 1655 1662 }); 1656 1663 1657 - function handleSuggestionAction(action) { 1664 + function handleSuggestionAction(action: 'accept' | 'reject'): void { 1658 1665 if (!activeSuggestion) return; 1659 1666 const { id, type } = activeSuggestion; 1660 1667 const markType = type === 'insert' ? 'suggestionInsert' : 'suggestionDelete'; ··· 1851 1858 } 1852 1859 } 1853 1860 1854 - function scrollToHeading(heading) { 1861 + function scrollToHeading(heading: { id: string; level: number; text: string }): void { 1855 1862 // Find the heading node in the editor and scroll to it 1856 1863 const { doc } = editor.state; 1857 1864 let targetPos = null; ··· 1973 1980 const linkPreviewState = new LinkPreviewState(); 1974 1981 const linkPreview = $('link-preview'); 1975 1982 const linkPreviewUrl = $('link-preview-url'); 1976 - let linkPreviewTimeout = null; 1983 + let linkPreviewTimeout: ReturnType<typeof setTimeout> | null = null; 1977 1984 1978 - function showLinkPreview(linkEl) { 1985 + function showLinkPreview(linkEl: Element): void { 1979 1986 const href = linkEl.getAttribute('href'); 1980 1987 if (!href) return; 1981 1988
+10 -13
src/docs/markdown-export.js src/docs/markdown-export.ts
··· 16 16 * Create and configure a TurndownService instance. 17 17 * Extracted as a function so it can be called once at module load. 18 18 */ 19 - function createTurndownService() { 19 + function createTurndownService(): TurndownService { 20 20 const td = new TurndownService({ 21 21 headingStyle: 'atx', // # style headings 22 22 hr: '---', // Horizontal rules ··· 35 35 // The default GFM plugin may use single ~ for <s>/<del> — we want ~~ 36 36 td.addRule('strikethrough', { 37 37 filter: ['del', 's'], 38 - replacement: function (content) { 38 + replacement: function (content: string): string { 39 39 return '~~' + content + '~~'; 40 40 }, 41 41 }); 42 42 43 43 // Custom rule for TipTap-style task lists (data-type="taskList") 44 44 td.addRule('tiptapTaskList', { 45 - filter: function (node) { 45 + filter: function (node: HTMLElement): boolean { 46 46 return ( 47 47 node.nodeName === 'LI' && 48 48 node.getAttribute('data-type') === 'taskItem' 49 49 ); 50 50 }, 51 - replacement: function (content, node) { 51 + replacement: function (content: string, node: HTMLElement): string { 52 52 const checked = node.getAttribute('data-checked') === 'true'; 53 53 const prefix = checked ? '[x] ' : '[ ] '; 54 54 // Clean up the content — remove label/checkbox artifacts ··· 62 62 63 63 // Custom rule: preserve language class on code blocks 64 64 td.addRule('fencedCodeWithLang', { 65 - filter: function (node) { 65 + filter: function (node: HTMLElement): boolean { 66 66 return ( 67 67 node.nodeName === 'PRE' && 68 - node.firstChild && 69 - node.firstChild.nodeName === 'CODE' 68 + node.firstChild !== null && 69 + (node.firstChild as HTMLElement).nodeName === 'CODE' 70 70 ); 71 71 }, 72 - replacement: function (_content, node) { 73 - const code = node.firstChild; 72 + replacement: function (_content: string, node: HTMLElement): string { 73 + const code = node.firstChild as HTMLElement; 74 74 const className = code.getAttribute('class') || ''; 75 75 const langMatch = className.match(/language-(\S+)/); 76 76 const lang = langMatch ? langMatch[1] : ''; ··· 86 86 87 87 /** 88 88 * Convert HTML to Markdown. 89 - * 90 - * @param {string} html - HTML string (e.g., from editor.getHTML()) 91 - * @returns {string} Markdown string 92 89 */ 93 - export function htmlToMarkdown(html) { 90 + export function htmlToMarkdown(html: string): string { 94 91 if (!html) return ''; 95 92 return turndownService.turndown(html); 96 93 }
+6 -8
src/docs/markdown-parser.js src/docs/markdown-parser.ts
··· 10 10 */ 11 11 12 12 import MarkdownIt from 'markdown-it'; 13 + import type { Token, Renderer, MarkdownItOptions, MarkdownItPlugin } from 'markdown-it'; 13 14 14 15 // Initialize markdown-it with GFM-like defaults 15 16 const md = new MarkdownIt({ ··· 29 30 * Custom plugin: task list checkboxes (GFM style) 30 31 * Converts `- [ ] text` and `- [x] text` into checkbox list items. 31 32 */ 32 - function taskListPlugin(md) { 33 + const taskListPlugin: MarkdownItPlugin = function taskListPlugin(md: MarkdownIt): void { 33 34 const defaultRender = md.renderer.rules.list_item_open || 34 - function (tokens, idx, options, env, self) { 35 + function (tokens: Token[], idx: number, options: MarkdownItOptions, _env: unknown, self: Renderer): string { 35 36 return self.renderToken(tokens, idx, options); 36 37 }; 37 38 38 - md.renderer.rules.list_item_open = function (tokens, idx, options, env, self) { 39 + md.renderer.rules.list_item_open = function (tokens: Token[], idx: number, options: MarkdownItOptions, env: unknown, self: Renderer): string { 39 40 // Look at the inline content of this list item 40 41 const contentToken = tokens[idx + 2]; // list_item_open -> paragraph_open -> inline 41 42 if (contentToken && contentToken.type === 'inline' && contentToken.content) { ··· 57 58 } 58 59 return defaultRender(tokens, idx, options, env, self); 59 60 }; 60 - } 61 + }; 61 62 62 63 md.use(taskListPlugin); 63 64 64 65 /** 65 66 * Convert a markdown string to HTML. 66 - * 67 - * @param {string} mdString - Raw markdown content 68 - * @returns {string} HTML string suitable for TipTap editor 69 67 */ 70 - export function markdownToHtml(mdString) { 68 + export function markdownToHtml(mdString: string): string { 71 69 if (!mdString) return ''; 72 70 return md.render(mdString); 73 71 }
-87
src/docs/markdown-toggle.js
··· 1 - /** 2 - * Markdown Toggle (Source View) 3 - * 4 - * Manages toggling between WYSIWYG (TipTap) and raw markdown editing modes. 5 - * Pure state management — no DOM manipulation. The caller (main.js) handles 6 - * the actual UI show/hide of editor vs textarea. 7 - * 8 - * Usage: 9 - * const toggle = createMarkdownToggle({ 10 - * getEditorHtml: () => editor.getHTML(), 11 - * setEditorHtml: (html) => editor.commands.setContent(html), 12 - * htmlToMarkdown: (html) => htmlToMarkdown(html), 13 - * markdownToHtml: (md) => markdownToHtml(md), 14 - * onModeChange: (mode) => { ... }, // optional callback 15 - * }); 16 - * 17 - * toggle.toggle(); // Switch modes 18 - * toggle.getMode(); // Current mode 19 - */ 20 - 21 - export const TOGGLE_MODE = Object.freeze({ 22 - WYSIWYG: 'wysiwyg', 23 - MARKDOWN: 'markdown', 24 - }); 25 - 26 - /** 27 - * Create a markdown toggle state manager. 28 - * 29 - * @param {Object} opts 30 - * @param {() => string} opts.getEditorHtml - Get current TipTap HTML content 31 - * @param {(html: string) => void} opts.setEditorHtml - Set TipTap HTML content 32 - * @param {(html: string) => string} opts.htmlToMarkdown - Convert HTML to markdown 33 - * @param {(md: string) => string} opts.markdownToHtml - Convert markdown to HTML 34 - * @param {(mode: string) => void} [opts.onModeChange] - Optional callback on mode change 35 - * @returns {Object} Toggle API 36 - */ 37 - export function createMarkdownToggle(opts) { 38 - const { getEditorHtml, setEditorHtml, htmlToMarkdown, markdownToHtml, onModeChange } = opts; 39 - 40 - let mode = TOGGLE_MODE.WYSIWYG; 41 - let markdownContent = ''; 42 - 43 - function toggle() { 44 - if (mode === TOGGLE_MODE.WYSIWYG) { 45 - // Switching TO markdown mode: convert current editor HTML to markdown 46 - markdownContent = htmlToMarkdown(getEditorHtml()); 47 - mode = TOGGLE_MODE.MARKDOWN; 48 - } else { 49 - // Switching BACK to WYSIWYG: parse markdown to HTML and load into editor 50 - const html = markdownToHtml(markdownContent); 51 - setEditorHtml(html); 52 - markdownContent = ''; 53 - mode = TOGGLE_MODE.WYSIWYG; 54 - } 55 - 56 - if (onModeChange) { 57 - onModeChange(mode); 58 - } 59 - } 60 - 61 - function getMode() { 62 - return mode; 63 - } 64 - 65 - function isMarkdownMode() { 66 - return mode === TOGGLE_MODE.MARKDOWN; 67 - } 68 - 69 - function getMarkdownContent() { 70 - return markdownContent; 71 - } 72 - 73 - function setMarkdownContent(content) { 74 - if (mode === TOGGLE_MODE.MARKDOWN) { 75 - markdownContent = content; 76 - } 77 - // Ignored in WYSIWYG mode 78 - } 79 - 80 - return { 81 - toggle, 82 - getMode, 83 - isMarkdownMode, 84 - getMarkdownContent, 85 - setMarkdownContent, 86 - }; 87 - }
+70
src/docs/markdown-toggle.ts
··· 1 + /** 2 + * Markdown Toggle (Source View) 3 + * 4 + * Manages toggling between WYSIWYG (TipTap) and raw markdown editing modes. 5 + * Pure state management — no DOM manipulation. The caller (main.js) handles 6 + * the actual UI show/hide of editor vs textarea. 7 + */ 8 + import type { MarkdownToggleOptions, MarkdownToggleApi } from './types.js'; 9 + 10 + export const TOGGLE_MODE = Object.freeze({ 11 + WYSIWYG: 'wysiwyg' as const, 12 + MARKDOWN: 'markdown' as const, 13 + }); 14 + 15 + export type ToggleMode = typeof TOGGLE_MODE[keyof typeof TOGGLE_MODE]; 16 + 17 + /** 18 + * Create a markdown toggle state manager. 19 + */ 20 + export function createMarkdownToggle(opts: MarkdownToggleOptions): MarkdownToggleApi { 21 + const { getEditorHtml, setEditorHtml, htmlToMarkdown, markdownToHtml, onModeChange } = opts; 22 + 23 + let mode: ToggleMode = TOGGLE_MODE.WYSIWYG; 24 + let markdownContent = ''; 25 + 26 + function toggle(): void { 27 + if (mode === TOGGLE_MODE.WYSIWYG) { 28 + // Switching TO markdown mode: convert current editor HTML to markdown 29 + markdownContent = htmlToMarkdown(getEditorHtml()); 30 + mode = TOGGLE_MODE.MARKDOWN; 31 + } else { 32 + // Switching BACK to WYSIWYG: parse markdown to HTML and load into editor 33 + const html = markdownToHtml(markdownContent); 34 + setEditorHtml(html); 35 + markdownContent = ''; 36 + mode = TOGGLE_MODE.WYSIWYG; 37 + } 38 + 39 + if (onModeChange) { 40 + onModeChange(mode); 41 + } 42 + } 43 + 44 + function getMode(): string { 45 + return mode; 46 + } 47 + 48 + function isMarkdownMode(): boolean { 49 + return mode === TOGGLE_MODE.MARKDOWN; 50 + } 51 + 52 + function getMarkdownContent(): string { 53 + return markdownContent; 54 + } 55 + 56 + function setMarkdownContent(content: string): void { 57 + if (mode === TOGGLE_MODE.MARKDOWN) { 58 + markdownContent = content; 59 + } 60 + // Ignored in WYSIWYG mode 61 + } 62 + 63 + return { 64 + toggle, 65 + getMode, 66 + isMarkdownMode, 67 + getMarkdownContent, 68 + setMarkdownContent, 69 + }; 70 + }
+34 -15
src/docs/outline.js src/docs/outline.ts
··· 4 4 * Extracts headings (H1, H2, H3) from editor content and builds 5 5 * a navigable tree for the outline sidebar panel. 6 6 */ 7 + import type { OutlineItem, OutlineTreeNode } from './types.js'; 8 + 9 + interface EditorJsonNode { 10 + type: string; 11 + attrs?: { level?: number }; 12 + content?: EditorJsonChild[]; 13 + } 14 + 15 + interface EditorJsonChild { 16 + type: string; 17 + text?: string; 18 + } 19 + 20 + interface EditorJson { 21 + content?: EditorJsonNode[]; 22 + } 7 23 8 24 /** 9 25 * Generate a URL-safe anchor ID from heading text. 10 26 * Appends index suffix for uniqueness when index > 0. 11 27 */ 12 - export function generateHeadingId(text, index) { 28 + export function generateHeadingId(text: string, index?: number): string { 13 29 let id = text 14 30 .toLowerCase() 15 31 .replace(/[^a-z0-9\s-]/g, '') ··· 26 42 /** 27 43 * Extract heading text from a heading node's content array. 28 44 */ 29 - function getHeadingText(node) { 45 + function getHeadingText(node: EditorJsonNode): string { 30 46 if (!node.content || !Array.isArray(node.content)) return ''; 31 47 return node.content 32 48 .filter((child) => child.type === 'text') ··· 38 54 * Extract all H1, H2, H3 headings from editor JSON content. 39 55 * Returns a flat array of { level, text, id } objects. 40 56 */ 41 - export function extractHeadings(json) { 57 + export function extractHeadings(json: EditorJson): OutlineItem[] { 42 58 if (!json || !json.content) return []; 43 59 44 - const headings = []; 45 - const idCounts = {}; 60 + const headings: OutlineItem[] = []; 61 + const idCounts: Record<string, number> = {}; 46 62 47 63 for (const node of json.content) { 48 64 if (node.type !== 'heading') continue; 49 65 const level = node.attrs?.level; 50 - if (level < 1 || level > 3) continue; 66 + if (level === undefined || level < 1 || level > 3) continue; 51 67 52 68 const text = getHeadingText(node); 53 69 const baseId = generateHeadingId(text); ··· 66 82 * Build a nested tree from a flat list of headings. 67 83 * H2 nests under preceding H1, H3 nests under preceding H2. 68 84 */ 69 - export function buildOutlineTree(headings) { 85 + export function buildOutlineTree(headings: OutlineItem[]): OutlineTreeNode[] { 70 86 if (!headings || headings.length === 0) return []; 71 87 72 - const root = []; 73 - const stack = []; // stack of { node, level } 88 + const root: OutlineTreeNode[] = []; 89 + const stack: OutlineTreeNode[] = []; // stack of tree nodes 74 90 75 91 for (const heading of headings) { 76 - const node = { ...heading, children: [] }; 92 + const node: OutlineTreeNode = { ...heading, children: [] }; 77 93 78 94 // Pop stack until we find a parent with a lower level 79 95 while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) { ··· 96 112 * Manages outline sidebar state. 97 113 */ 98 114 export class OutlineState { 115 + isOpen: boolean; 116 + headings: OutlineItem[]; 117 + 99 118 constructor() { 100 119 this.isOpen = false; 101 120 this.headings = []; 102 121 } 103 122 104 - toggle() { 123 + toggle(): void { 105 124 this.isOpen = !this.isOpen; 106 125 } 107 126 108 - open() { 127 + open(): void { 109 128 this.isOpen = true; 110 129 } 111 130 112 - close() { 131 + close(): void { 113 132 this.isOpen = false; 114 133 } 115 134 116 - updateHeadings(headings) { 135 + updateHeadings(headings: OutlineItem[]): void { 117 136 this.headings = headings; 118 137 } 119 138 120 - getTree() { 139 + getTree(): OutlineTreeNode[] { 121 140 return buildOutlineTree(this.headings); 122 141 } 123 142 }
+4 -14
src/docs/pdf-export.js src/docs/pdf-export.ts
··· 4 4 * Uses html2pdf.js to convert the editor content into a downloadable PDF. 5 5 * Always exports in light mode regardless of current theme. 6 6 */ 7 + import type { PdfExportOptions } from './types.js'; 7 8 8 9 /** 9 10 * Build a self-contained HTML string suitable for PDF rendering. 10 11 * Extracted as a pure function for testability. 11 - * 12 - * @param {string} editorHtml - The TipTap editor's HTML output 13 - * @param {string} title - Document title 14 - * @returns {string} Full HTML document string for pdf rendering 15 12 */ 16 - export function buildPdfHtml(editorHtml, title) { 13 + export function buildPdfHtml(editorHtml: string, title: string): string { 17 14 return `<!DOCTYPE html> 18 15 <html lang="en"> 19 16 <head> ··· 50 47 51 48 /** 52 49 * Derive a safe filename from a document title. 53 - * 54 - * @param {string} title - The document title 55 - * @returns {string} Sanitized filename (without extension) 56 50 */ 57 - export function pdfFilename(title) { 51 + export function pdfFilename(title: string): string { 58 52 const clean = (title || '').trim() || 'Untitled Document'; 59 53 return clean.replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_'); 60 54 } ··· 62 56 /** 63 57 * Generate and download a PDF from editor content. 64 58 * This is the DOM-coupled entry point — not unit-testable. 65 - * 66 - * @param {object} opts 67 - * @param {string} opts.editorHtml - HTML content from editor.getHTML() 68 - * @param {string} opts.title - Document title 69 59 */ 70 - export async function exportPdf({ editorHtml, title }) { 60 + export async function exportPdf({ editorHtml, title }: PdfExportOptions): Promise<void> { 71 61 const html2pdf = (await import('html2pdf.js')).default; 72 62 73 63 const container = document.createElement('div');
+22 -14
src/docs/search-replace.js src/docs/search-replace.ts
··· 6 6 */ 7 7 8 8 import { Extension } from '@tiptap/core'; 9 + import type { Editor } from '@tiptap/core'; 9 10 import { Plugin, PluginKey } from '@tiptap/pm/state'; 11 + import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; 10 12 import { Decoration, DecorationSet } from '@tiptap/pm/view'; 13 + import type { SearchResult } from './types.js'; 11 14 12 15 const searchPluginKey = new PluginKey('searchReplace'); 13 16 ··· 15 18 * Find all occurrences of `search` inside the ProseMirror doc. 16 19 * Returns an array of { from, to } positions. 17 20 */ 18 - function findMatches(doc, search, caseSensitive) { 21 + function findMatches(doc: ProseMirrorNode, search: string, caseSensitive: boolean): SearchResult[] { 19 22 if (!search) return []; 20 - const results = []; 23 + const results: SearchResult[] = []; 21 24 const flags = caseSensitive ? 'g' : 'gi'; 22 25 const regex = new RegExp(escapeRegex(search), flags); 23 26 24 27 doc.descendants((node, pos) => { 25 28 if (!node.isText) return; 26 29 const text = node.text; 27 - let match; 30 + if (!text) return; 31 + let match: RegExpExecArray | null; 28 32 while ((match = regex.exec(text)) !== null) { 29 33 results.push({ 30 34 from: pos + match.index, ··· 36 40 return results; 37 41 } 38 42 39 - function escapeRegex(s) { 43 + function escapeRegex(s: string): string { 40 44 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 41 45 } 42 46 43 - function buildDecorations(doc, matches, activeIndex) { 44 - const decorations = []; 47 + function buildDecorations(doc: ProseMirrorNode, matches: SearchResult[], activeIndex: number): DecorationSet { 48 + const decorations: Decoration[] = []; 45 49 for (let i = 0; i < matches.length; i++) { 46 50 const { from, to } = matches[i]; 47 51 const className = i === activeIndex ··· 52 56 return DecorationSet.create(doc, decorations); 53 57 } 54 58 55 - export const SearchReplace = Extension.create({ 59 + interface SearchReplaceOptions { 60 + onStateChange?: () => void; 61 + } 62 + 63 + export const SearchReplace = Extension.create<SearchReplaceOptions>({ 56 64 name: 'searchReplace', 57 65 58 66 addStorage() { ··· 60 68 searchTerm: '', 61 69 replaceTerm: '', 62 70 caseSensitive: false, 63 - matches: [], 71 + matches: [] as SearchResult[], 64 72 activeIndex: -1, 65 73 isOpen: false, 66 74 showReplace: false, ··· 94 102 this.options.onStateChange?.(); 95 103 return true; 96 104 }, 97 - setSearchTerm: (term) => ({ editor }) => { 105 + setSearchTerm: (term: string) => ({ editor }) => { 98 106 const storage = editor.storage.searchReplace; 99 107 storage.searchTerm = term; 100 108 const matches = findMatches(editor.state.doc, term, storage.caseSensitive); ··· 117 125 this.options.onStateChange?.(); 118 126 return true; 119 127 }, 120 - setReplaceTerm: (term) => ({ editor }) => { 128 + setReplaceTerm: (term: string) => ({ editor }) => { 121 129 editor.storage.searchReplace.replaceTerm = term; 122 130 return true; 123 131 }, ··· 242 250 init() { 243 251 return DecorationSet.empty; 244 252 }, 245 - apply(tr, oldDecorations, oldState, newState) { 253 + apply(tr, oldDecorations, _oldState, newState) { 246 254 const meta = tr.getMeta(searchPluginKey); 247 255 if (meta !== undefined) { 248 256 const storage = extensionThis.editor.storage.searchReplace; ··· 267 275 }, 268 276 props: { 269 277 decorations(state) { 270 - return this.getState(state); 278 + return this.getState(state) as DecorationSet; 271 279 }, 272 280 }, 273 281 }), ··· 275 283 }, 276 284 }); 277 285 278 - function scrollToMatch(editor, match) { 286 + function scrollToMatch(editor: Editor, match: SearchResult): void { 279 287 if (!match) return; 280 288 try { 281 289 const dom = editor.view.domAtPos(match.from); 282 290 if (dom?.node) { 283 291 const el = dom.node.nodeType === Node.TEXT_NODE ? dom.node.parentElement : dom.node; 284 - el?.scrollIntoView({ block: 'center', behavior: 'smooth' }); 292 + (el as Element)?.scrollIntoView({ block: 'center', behavior: 'smooth' }); 285 293 } 286 294 } catch { 287 295 // Position might be invalid after edits
+19 -9
src/docs/search-state.js src/docs/search-state.ts
··· 4 4 * Handles match finding, navigation, and replacement. 5 5 * Independent of TipTap/ProseMirror so it can be tested in isolation. 6 6 */ 7 + import type { SearchResult } from './types.js'; 8 + 9 + interface SearchStateOptions { 10 + caseSensitive?: boolean; 11 + } 12 + 7 13 export class SearchState { 8 - constructor(opts = {}) { 14 + caseSensitive: boolean; 15 + matches: SearchResult[]; 16 + currentIndex: number; 17 + 18 + constructor(opts: SearchStateOptions = {}) { 9 19 this.caseSensitive = opts.caseSensitive ?? false; 10 20 this.matches = []; 11 21 this.currentIndex = -1; ··· 15 25 * Find all occurrences of `term` in `text`. 16 26 * Returns array of { from, to } positions. 17 27 */ 18 - findMatches(text, term) { 28 + findMatches(text: string, term: string): SearchResult[] { 19 29 this.matches = []; 20 30 this.currentIndex = -1; 21 31 ··· 42 52 } 43 53 44 54 /** Advance to the next match (wraps around). */ 45 - next() { 55 + next(): void { 46 56 if (this.matches.length === 0) return; 47 57 this.currentIndex = (this.currentIndex + 1) % this.matches.length; 48 58 } 49 59 50 60 /** Go to the previous match (wraps around). */ 51 - prev() { 61 + prev(): void { 52 62 if (this.matches.length === 0) return; 53 63 this.currentIndex = (this.currentIndex - 1 + this.matches.length) % this.matches.length; 54 64 } 55 65 56 66 /** Replace the current match and return the new text. */ 57 - replaceOne(text, replacement) { 67 + replaceOne(text: string, replacement: string): { text: string; replaced: boolean } { 58 68 if (this.matches.length === 0 || this.currentIndex < 0) { 59 69 return { text, replaced: false }; 60 70 } ··· 66 76 } 67 77 68 78 /** Replace all matches and return the new text and count. */ 69 - replaceAll(text, replacement) { 79 + replaceAll(text: string, replacement: string): { text: string; count: number } { 70 80 if (this.matches.length === 0) { 71 81 return { text, count: 0 }; 72 82 } ··· 82 92 } 83 93 84 94 /** Toggle case sensitivity. */ 85 - setCaseSensitive(value) { 95 + setCaseSensitive(value: boolean): void { 86 96 this.caseSensitive = value; 87 97 } 88 98 89 99 /** Number of matches found. */ 90 - get matchCount() { 100 + get matchCount(): number { 91 101 return this.matches.length; 92 102 } 93 103 94 104 /** The current match object, or null. */ 95 - get currentMatch() { 105 + get currentMatch(): SearchResult | null { 96 106 if (this.currentIndex < 0 || this.currentIndex >= this.matches.length) { 97 107 return null; 98 108 }
+20 -26
src/docs/slash-menu.js src/docs/slash-menu.ts
··· 4 4 * Pure logic module: command definitions, filtering, and menu state. 5 5 * No DOM dependencies — rendering is handled in main.js and the TipTap extension. 6 6 */ 7 + import type { SlashCommandItem, SlashCommandCategory, SlashCommandGroup } from './types.js'; 7 8 8 9 // ============================================================ 9 10 // Placeholder strings (used by Placeholder extension config) ··· 19 20 // Categories 20 21 // ============================================================ 21 22 22 - export const SLASH_COMMAND_CATEGORIES = [ 23 + export const SLASH_COMMAND_CATEGORIES: SlashCommandCategory[] = [ 23 24 { id: 'text', label: 'Text' }, 24 25 { id: 'lists', label: 'Lists' }, 25 26 { id: 'media', label: 'Media' }, ··· 32 33 // Command Items 33 34 // ============================================================ 34 35 35 - export const SLASH_COMMAND_ITEMS = [ 36 + export const SLASH_COMMAND_ITEMS: SlashCommandItem[] = [ 36 37 // --- Text --- 37 38 { 38 39 id: 'paragraph', ··· 181 182 /** 182 183 * Filter slash command items by a search query. 183 184 * Matches against name, description, and category label. 184 - * 185 - * @param {string} query - The search string 186 - * @returns {Array} Filtered command items 187 185 */ 188 - export function filterCommands(query) { 186 + export function filterCommands(query: string): SlashCommandItem[] { 189 187 const q = (query || '').trim().toLowerCase(); 190 188 if (!q) return [...SLASH_COMMAND_ITEMS]; 191 189 192 - const catLabelMap = {}; 190 + const catLabelMap: Record<string, string> = {}; 193 191 for (const cat of SLASH_COMMAND_CATEGORIES) { 194 192 catLabelMap[cat.id] = cat.label.toLowerCase(); 195 193 } ··· 204 202 205 203 /** 206 204 * Look up a command by its id. 207 - * 208 - * @param {string} id 209 - * @returns {object|null} 210 205 */ 211 - export function findCommandById(id) { 206 + export function findCommandById(id: string): SlashCommandItem | null { 212 207 if (!id) return null; 213 208 return SLASH_COMMAND_ITEMS.find(item => item.id === id) || null; 214 209 } 215 210 216 211 /** 217 212 * Get all items in a given category. 218 - * 219 - * @param {string} categoryId 220 - * @returns {Array} 221 213 */ 222 - export function getCategoryItems(categoryId) { 214 + export function getCategoryItems(categoryId: string): SlashCommandItem[] { 223 215 return SLASH_COMMAND_ITEMS.filter(item => item.category === categoryId); 224 216 } 225 217 ··· 232 224 * Pure state object — no DOM coupling. 233 225 */ 234 226 export class SlashMenuState { 227 + isOpen: boolean; 228 + query: string; 229 + selectedIndex: number; 230 + 235 231 constructor() { 236 232 this.isOpen = false; 237 233 this.query = ''; 238 234 this.selectedIndex = 0; 239 235 } 240 236 241 - open() { 237 + open(): void { 242 238 this.isOpen = true; 243 239 this.query = ''; 244 240 this.selectedIndex = 0; 245 241 } 246 242 247 - close() { 243 + close(): void { 248 244 this.isOpen = false; 249 245 this.query = ''; 250 246 this.selectedIndex = 0; 251 247 } 252 248 253 - setQuery(query) { 249 + setQuery(query: string): void { 254 250 this.query = query; 255 251 this.selectedIndex = 0; 256 252 } 257 253 258 - getFilteredItems() { 254 + getFilteredItems(): SlashCommandItem[] { 259 255 return filterCommands(this.query); 260 256 } 261 257 262 - moveDown() { 258 + moveDown(): void { 263 259 const items = this.getFilteredItems(); 264 260 if (items.length === 0) return; 265 261 this.selectedIndex = (this.selectedIndex + 1) % items.length; 266 262 } 267 263 268 - moveUp() { 264 + moveUp(): void { 269 265 const items = this.getFilteredItems(); 270 266 if (items.length === 0) return; 271 267 this.selectedIndex = (this.selectedIndex - 1 + items.length) % items.length; 272 268 } 273 269 274 - getSelectedItem() { 270 + getSelectedItem(): SlashCommandItem | null { 275 271 if (!this.isOpen) return null; 276 272 const items = this.getFilteredItems(); 277 273 if (items.length === 0) return null; ··· 281 277 /** 282 278 * Return filtered items grouped by category, in category order. 283 279 * Only includes categories that have matching items. 284 - * 285 - * @returns {Array<{id: string, label: string, items: Array}>} 286 280 */ 287 - getGroupedItems() { 281 + getGroupedItems(): SlashCommandGroup[] { 288 282 const filtered = this.getFilteredItems(); 289 - const groups = []; 283 + const groups: SlashCommandGroup[] = []; 290 284 291 285 for (const cat of SLASH_COMMAND_CATEGORIES) { 292 286 const catItems = filtered.filter(item => item.category === cat.id);
+2 -7
src/docs/tab-handler.js src/docs/tab-handler.ts
··· 4 4 * Pure function: given editor context, returns the action to take. 5 5 * This keeps the logic testable without requiring a TipTap instance. 6 6 */ 7 + import type { TabAction, TabContext } from './types.js'; 7 8 8 9 /** 9 10 * Determine what Tab/Shift+Tab should do in the docs editor. 10 - * 11 - * @param {object} ctx 12 - * @param {boolean} ctx.isInList - Cursor is inside a list (bullet, ordered, or task) 13 - * @param {boolean} ctx.isInCodeBlock - Cursor is inside a code block 14 - * @param {boolean} ctx.shiftKey - Shift key is held 15 - * @returns {'indent' | 'outdent' | 'insertTab' | 'noop'} 16 11 */ 17 - export function resolveTabAction({ isInList, isInCodeBlock, shiftKey }) { 12 + export function resolveTabAction({ isInList, isInCodeBlock, shiftKey }: TabContext): TabAction { 18 13 if (isInList) { 19 14 return shiftKey ? 'outdent' : 'indent'; 20 15 }
src/docs/tab-support.js src/docs/tab-support.ts
+10 -6
src/docs/table-toolbar.js src/docs/table-toolbar.ts
··· 5 5 * providing commands for row/column manipulation, cell merging, 6 6 * header toggling, and cell background color. 7 7 */ 8 + import type { TableCommand, TableCommandMap, CellAttrs, TooltipPosition } from './types.js'; 8 9 9 10 /** 10 11 * Command definitions for all table operations. 11 12 * Each maps to a TipTap table extension command. 12 13 */ 13 - export const TABLE_COMMANDS = { 14 + export const TABLE_COMMANDS: TableCommandMap = { 14 15 addRowBefore: { 15 16 label: 'Add row above', 16 17 icon: '&#x2B06;', ··· 62 63 * Manages visibility and position of the floating table toolbar. 63 64 */ 64 65 export class TableToolbarState { 66 + visible: boolean; 67 + position: TooltipPosition | null; 68 + 65 69 constructor() { 66 70 this.visible = false; 67 71 this.position = null; 68 72 } 69 73 70 - show(pos) { 74 + show(pos: TooltipPosition): void { 71 75 this.visible = true; 72 76 this.position = { ...pos }; 73 77 } 74 78 75 - hide() { 79 + hide(): void { 76 80 this.visible = false; 77 81 this.position = null; 78 82 } 79 83 80 - updatePosition(pos) { 84 + updatePosition(pos: TooltipPosition): void { 81 85 this.position = { ...pos }; 82 86 } 83 87 } ··· 85 89 /** 86 90 * Parse cell background color from TipTap cell attributes. 87 91 */ 88 - export function parseCellColor(attrs) { 92 + export function parseCellColor(attrs: CellAttrs | null | undefined): string | null { 89 93 if (!attrs) return null; 90 94 if (attrs.background) return attrs.background; 91 95 if (attrs.backgroundColor) return attrs.backgroundColor; ··· 96 100 * Normalize a color string to lowercase hex. 97 101 * Handles 3-digit shorthand (#f00 -> #ff0000). 98 102 */ 99 - export function normalizeCellColor(color) { 103 + export function normalizeCellColor(color: string | null | undefined): string | null { 100 104 if (!color) return null; 101 105 const trimmed = color.trim(); 102 106 if (!trimmed) return null;
+224
src/docs/types.ts
··· 1 + /** 2 + * Type definitions for the docs module. 3 + */ 4 + 5 + import type { Editor } from '@tiptap/core'; 6 + 7 + export interface SearchState { 8 + query: string; 9 + caseSensitive: boolean; 10 + regex: boolean; 11 + results: SearchResult[]; 12 + currentIndex: number; 13 + } 14 + 15 + export interface SearchResult { 16 + from: number; 17 + to: number; 18 + } 19 + 20 + export interface OutlineItem { 21 + level: number; 22 + text: string; 23 + id: string; 24 + } 25 + 26 + export interface OutlineTreeNode extends OutlineItem { 27 + children: OutlineTreeNode[]; 28 + } 29 + 30 + export interface SlashCommandItem { 31 + id: string; 32 + name: string; 33 + description: string; 34 + category: string; 35 + icon: string; 36 + shortcut: string | null; 37 + } 38 + 39 + export interface SlashCommandCategory { 40 + id: string; 41 + label: string; 42 + } 43 + 44 + export interface SlashCommandGroup { 45 + id: string; 46 + label: string; 47 + items: SlashCommandItem[]; 48 + } 49 + 50 + export interface MarkdownOptions { 51 + gfm?: boolean; 52 + tables?: boolean; 53 + taskLists?: boolean; 54 + } 55 + 56 + export interface BlockHandlePosition { 57 + top: number; 58 + left: number; 59 + } 60 + 61 + export interface BlockHandleAction { 62 + id: string; 63 + label: string; 64 + icon: string; 65 + } 66 + 67 + export interface TurnIntoItem { 68 + id: string; 69 + name: string; 70 + icon: string; 71 + } 72 + 73 + export interface TooltipPosition { 74 + top: number; 75 + left: number; 76 + } 77 + 78 + export interface LinkPreviewData { 79 + href: string; 80 + position: TooltipPosition; 81 + } 82 + 83 + export interface TableCommand { 84 + label: string; 85 + icon: string; 86 + command: string; 87 + } 88 + 89 + export interface TableCommandMap { 90 + [key: string]: TableCommand; 91 + } 92 + 93 + export interface CellAttrs { 94 + background?: string; 95 + backgroundColor?: string; 96 + } 97 + 98 + export interface PdfExportOptions { 99 + editorHtml: string; 100 + title: string; 101 + } 102 + 103 + export interface DocxConvertResult { 104 + html: string; 105 + messages: DocxMessage[]; 106 + } 107 + 108 + export interface DocxMessage { 109 + type: string; 110 + message: string; 111 + } 112 + 113 + export type TabAction = 'indent' | 'outdent' | 'insertTab' | 'noop'; 114 + 115 + export interface TabContext { 116 + isInList: boolean; 117 + isInCodeBlock: boolean; 118 + shiftKey: boolean; 119 + } 120 + 121 + export interface AutoformatRule { 122 + id: string; 123 + description: string; 124 + trigger: string; 125 + regex: RegExp; 126 + source: string; 127 + custom: boolean; 128 + } 129 + 130 + export interface AutoformatMatch { 131 + id: string; 132 + match: RegExpMatchArray; 133 + } 134 + 135 + export interface ParsedLink { 136 + text: string; 137 + href: string; 138 + } 139 + 140 + export interface MarkdownToggleOptions { 141 + getEditorHtml: () => string; 142 + setEditorHtml: (html: string) => void; 143 + htmlToMarkdown: (html: string) => string; 144 + markdownToHtml: (md: string) => string; 145 + onModeChange?: (mode: string) => void; 146 + } 147 + 148 + export interface MarkdownToggleApi { 149 + toggle: () => void; 150 + getMode: () => string; 151 + isMarkdownMode: () => boolean; 152 + getMarkdownContent: () => string; 153 + setMarkdownContent: (content: string) => void; 154 + } 155 + 156 + export interface ShortcutEntry { 157 + keys: string[]; 158 + label: string; 159 + } 160 + 161 + export interface ShortcutCategory { 162 + category: string; 163 + shortcuts: ShortcutEntry[]; 164 + } 165 + 166 + export type CommandExecutor = (editor: Editor) => void; 167 + 168 + export interface SlashCommandsConfig { 169 + onStart?: (props: SuggestionCallbackProps) => void; 170 + onUpdate?: (props: SuggestionCallbackProps) => void; 171 + onExit?: () => void; 172 + onKeyDown?: (props: { event: KeyboardEvent }) => boolean; 173 + items: (query: string) => SlashCommandExecutableItem[]; 174 + } 175 + 176 + export interface SlashCommandExecutableItem extends SlashCommandItem { 177 + execute: CommandExecutor; 178 + } 179 + 180 + export interface SuggestionCallbackProps { 181 + query: string; 182 + command: (item: SlashCommandExecutableItem) => void; 183 + clientRect?: (() => DOMRect | null) | null; 184 + } 185 + 186 + export interface SearchReplaceStorage { 187 + searchTerm: string; 188 + replaceTerm: string; 189 + caseSensitive: boolean; 190 + matches: SearchResult[]; 191 + activeIndex: number; 192 + isOpen: boolean; 193 + showReplace: boolean; 194 + } 195 + 196 + export type BlockHandleMode = 'normal' | 'zen' | 'print'; 197 + 198 + export interface LinkAttrs { 199 + href?: string; 200 + } 201 + 202 + export interface CommentAttrs { 203 + commentId: string; 204 + author: string; 205 + timestamp: string; 206 + text: string; 207 + } 208 + 209 + export interface SuggestionMarkAttrs { 210 + suggestionId: string | null; 211 + author: string | null; 212 + timestamp: string | null; 213 + } 214 + 215 + export interface ActiveComment { 216 + id: string; 217 + element: Element; 218 + } 219 + 220 + export interface ActiveSuggestion { 221 + id: string; 222 + element: Element; 223 + type: 'insert' | 'delete'; 224 + }
+126
src/docs/vendor.d.ts
··· 1 + /** 2 + * Type declarations for third-party libraries without built-in types. 3 + */ 4 + 5 + declare module 'mammoth' { 6 + interface ConvertResult { 7 + value: string; 8 + messages: Array<{ type: string; message: string }>; 9 + } 10 + 11 + interface ConvertOptions { 12 + styleMap?: string[]; 13 + } 14 + 15 + interface BrowserInput { 16 + arrayBuffer: ArrayBuffer; 17 + } 18 + 19 + interface NodeInput { 20 + buffer: Buffer; 21 + } 22 + 23 + function convertToHtml( 24 + input: BrowserInput | NodeInput, 25 + options?: ConvertOptions 26 + ): Promise<ConvertResult>; 27 + 28 + export { convertToHtml, ConvertResult, ConvertOptions }; 29 + } 30 + 31 + declare module 'markdown-it' { 32 + interface MarkdownItOptions { 33 + html?: boolean; 34 + linkify?: boolean; 35 + typographer?: boolean; 36 + breaks?: boolean; 37 + } 38 + 39 + interface Token { 40 + type: string; 41 + tag: string; 42 + nesting: number; 43 + attrs: [string, string][] | null; 44 + map: [number, number] | null; 45 + level: number; 46 + children: Token[] | null; 47 + content: string; 48 + markup: string; 49 + info: string; 50 + meta: unknown; 51 + block: boolean; 52 + hidden: boolean; 53 + } 54 + 55 + interface RendererRuleFunction { 56 + ( 57 + tokens: Token[], 58 + idx: number, 59 + options: MarkdownItOptions, 60 + env: unknown, 61 + self: Renderer 62 + ): string; 63 + } 64 + 65 + interface Renderer { 66 + rules: { [name: string]: RendererRuleFunction | undefined }; 67 + renderToken( 68 + tokens: Token[], 69 + idx: number, 70 + options: MarkdownItOptions 71 + ): string; 72 + } 73 + 74 + interface MarkdownItPlugin { 75 + (md: MarkdownIt): void; 76 + } 77 + 78 + class MarkdownIt { 79 + constructor(options?: MarkdownItOptions); 80 + renderer: Renderer; 81 + render(src: string, env?: unknown): string; 82 + enable(name: string | string[]): MarkdownIt; 83 + disable(name: string | string[]): MarkdownIt; 84 + use(plugin: MarkdownItPlugin, ...params: unknown[]): MarkdownIt; 85 + } 86 + 87 + export default MarkdownIt; 88 + export { MarkdownItOptions, Token, Renderer, RendererRuleFunction, MarkdownItPlugin }; 89 + } 90 + 91 + declare module 'turndown' { 92 + interface TurndownOptions { 93 + headingStyle?: string; 94 + hr?: string; 95 + bulletListMarker?: string; 96 + codeBlockStyle?: string; 97 + fence?: string; 98 + emDelimiter?: string; 99 + strongDelimiter?: string; 100 + linkStyle?: string; 101 + } 102 + 103 + interface TurndownRule { 104 + filter: string | string[] | ((node: HTMLElement) => boolean); 105 + replacement: (content: string, node: HTMLElement) => string; 106 + } 107 + 108 + interface TurndownPlugin { 109 + (service: TurndownService): void; 110 + } 111 + 112 + class TurndownService { 113 + constructor(options?: TurndownOptions); 114 + turndown(html: string): string; 115 + addRule(key: string, rule: TurndownRule): TurndownService; 116 + use(plugin: TurndownPlugin | TurndownPlugin[]): TurndownService; 117 + } 118 + 119 + export default TurndownService; 120 + } 121 + 122 + declare module 'turndown-plugin-gfm' { 123 + import type TurndownService from 'turndown'; 124 + const gfm: (service: TurndownService) => void; 125 + export { gfm }; 126 + }
+9 -6
src/docs/zen-mode.js src/docs/zen-mode.ts
··· 19 19 * Manages zen mode state. 20 20 */ 21 21 export class ZenModeState { 22 + isActive: boolean; 23 + toggleCount: number; 24 + 22 25 constructor(active = false) { 23 26 this.isActive = active; 24 27 this.toggleCount = 0; 25 28 } 26 29 27 - toggle() { 30 + toggle(): void { 28 31 this.isActive = !this.isActive; 29 32 this.toggleCount++; 30 33 } 31 34 32 - activate() { 35 + activate(): void { 33 36 this.isActive = true; 34 37 } 35 38 36 - deactivate() { 39 + deactivate(): void { 37 40 this.isActive = false; 38 41 } 39 42 40 - getClassList() { 43 + getClassList(): string[] { 41 44 return this.isActive ? [ZEN_CLASS] : []; 42 45 } 43 46 44 - serialize() { 47 + serialize(): string { 45 48 return this.isActive ? 'true' : 'false'; 46 49 } 47 50 48 - static fromStored(value) { 51 + static fromStored(value: string | null): ZenModeState { 49 52 return new ZenModeState(value === 'true'); 50 53 } 51 54 }
+1 -1
src/index.html
··· 135 135 </div> 136 136 </div> 137 137 138 - <script type="module" src="./landing.js"></script> 138 + <script type="module" src="./landing.ts"></script> 139 139 <script> 140 140 // Theme toggle logic 141 141 (function() {
-66
src/landing-dragdrop.js
··· 1 - /** 2 - * Pure utility functions for drag-and-drop file import on the landing page. 3 - * Extracted for testability — no DOM or browser API access here. 4 - */ 5 - 6 - /** File extension to document type mapping. */ 7 - const EXT_TO_DOC_TYPE = { 8 - docx: 'doc', 9 - md: 'doc', 10 - xlsx: 'sheet', 11 - csv: 'sheet', 12 - }; 13 - 14 - /** File extension to import type mapping. */ 15 - const EXT_TO_IMPORT_TYPE = { 16 - docx: 'docx', 17 - md: 'md', 18 - xlsx: 'xlsx', 19 - csv: 'csv', 20 - }; 21 - 22 - /** 23 - * Get the document type ('doc' | 'sheet') for a filename based on its extension. 24 - * @param {string} filename 25 - * @returns {'doc' | 'sheet' | null} 26 - */ 27 - export function getFileType(filename) { 28 - if (!filename || typeof filename !== 'string') return null; 29 - const ext = filename.split('.').pop().toLowerCase(); 30 - return EXT_TO_DOC_TYPE[ext] || null; 31 - } 32 - 33 - /** 34 - * Get the import type for a filename based on its extension. 35 - * @param {string} filename 36 - * @returns {'docx' | 'xlsx' | 'csv' | 'md' | null} 37 - */ 38 - export function getImportType(filename) { 39 - if (!filename || typeof filename !== 'string') return null; 40 - const ext = filename.split('.').pop().toLowerCase(); 41 - return EXT_TO_IMPORT_TYPE[ext] || null; 42 - } 43 - 44 - /** 45 - * Build the sessionStorage key for a pending import. 46 - * @param {string} docId 47 - * @returns {string} 48 - */ 49 - export function pendingImportKey(docId) { 50 - return `pending-import-${docId}`; 51 - } 52 - 53 - /** 54 - * Build the editor URL path for a given document type and ID. 55 - * @param {'doc' | 'sheet'} type 56 - * @param {string} docId 57 - * @param {string} keyStr - base64url encryption key 58 - * @returns {string} 59 - */ 60 - export function buildEditorUrl(type, docId, keyStr) { 61 - const base = type === 'doc' ? '/docs' : '/sheets'; 62 - return `${base}/${docId}#${keyStr}`; 63 - } 64 - 65 - /** Supported file extensions for display purposes. */ 66 - export const SUPPORTED_EXTENSIONS = Object.keys(EXT_TO_DOC_TYPE);
+59
src/landing-dragdrop.ts
··· 1 + /** 2 + * Pure utility functions for drag-and-drop file import on the landing page. 3 + * Extracted for testability — no DOM or browser API access here. 4 + */ 5 + import type { DocType, ImportType } from './landing-types.js'; 6 + 7 + /** File extension to document type mapping. */ 8 + const EXT_TO_DOC_TYPE: Record<string, DocType> = { 9 + docx: 'doc', 10 + md: 'doc', 11 + xlsx: 'sheet', 12 + csv: 'sheet', 13 + }; 14 + 15 + /** File extension to import type mapping. */ 16 + const EXT_TO_IMPORT_TYPE: Record<string, ImportType> = { 17 + docx: 'docx', 18 + md: 'md', 19 + xlsx: 'xlsx', 20 + csv: 'csv', 21 + }; 22 + 23 + /** 24 + * Get the document type ('doc' | 'sheet') for a filename based on its extension. 25 + */ 26 + export function getFileType(filename: string): DocType | null { 27 + if (!filename || typeof filename !== 'string') return null; 28 + const ext = filename.split('.').pop()?.toLowerCase(); 29 + if (!ext) return null; 30 + return EXT_TO_DOC_TYPE[ext] || null; 31 + } 32 + 33 + /** 34 + * Get the import type for a filename based on its extension. 35 + */ 36 + export function getImportType(filename: string): ImportType | null { 37 + if (!filename || typeof filename !== 'string') return null; 38 + const ext = filename.split('.').pop()?.toLowerCase(); 39 + if (!ext) return null; 40 + return EXT_TO_IMPORT_TYPE[ext] || null; 41 + } 42 + 43 + /** 44 + * Build the sessionStorage key for a pending import. 45 + */ 46 + export function pendingImportKey(docId: string): string { 47 + return `pending-import-${docId}`; 48 + } 49 + 50 + /** 51 + * Build the editor URL path for a given document type and ID. 52 + */ 53 + export function buildEditorUrl(type: DocType, docId: string, keyStr: string): string { 54 + const base = type === 'doc' ? '/docs' : '/sheets'; 55 + return `${base}/${docId}#${keyStr}`; 56 + } 57 + 58 + /** Supported file extensions for display purposes. */ 59 + export const SUPPORTED_EXTENSIONS: string[] = Object.keys(EXT_TO_DOC_TYPE);
+67
src/landing-types.ts
··· 1 + /** 2 + * Type definitions for the landing page module. 3 + */ 4 + 5 + export interface DocumentMeta { 6 + id: string; 7 + type: 'doc' | 'sheet'; 8 + name_encrypted: string | null; 9 + created_at: string; 10 + updated_at: string; 11 + _decryptedName?: string; 12 + _keyStr?: string; 13 + } 14 + 15 + export interface SortConfig { 16 + field: SortField; 17 + direction: 'asc' | 'desc'; 18 + } 19 + 20 + export type SortField = 'updated' | 'created' | 'name' | 'type'; 21 + 22 + export interface Folder { 23 + id: string; 24 + name: string; 25 + createdAt: number; 26 + } 27 + 28 + export interface TrashEntry { 29 + id: string; 30 + deletedAt: number; 31 + } 32 + 33 + export interface FolderAssignments { 34 + [docId: string]: string; 35 + } 36 + 37 + export interface StarMap { 38 + [docId: string]: true; 39 + } 40 + 41 + export interface Breadcrumb { 42 + id: string | null; 43 + name: string; 44 + } 45 + 46 + export interface UsernameValidationResult { 47 + valid: boolean; 48 + error?: string; 49 + } 50 + 51 + export interface PurgeResult { 52 + kept: TrashEntry[]; 53 + expired: string[]; 54 + } 55 + 56 + export type DocType = 'doc' | 'sheet'; 57 + export type ImportType = 'docx' | 'xlsx' | 'csv' | 'md'; 58 + 59 + export interface SortLabels { 60 + [key: string]: string; 61 + } 62 + 63 + export interface PendingImport { 64 + name: string; 65 + type: ImportType; 66 + data: string; 67 + }
-356
src/landing-utils.js
··· 1 - /** 2 - * Pure utility functions for the landing page features. 3 - * Extracted for testability — no DOM or localStorage access here. 4 - */ 5 - 6 - // ============================================================ 7 - // Sort 8 - // ============================================================ 9 - 10 - /** Available sort criteria. */ 11 - export const SORT_OPTIONS = ['updated', 'created', 'name', 'type']; 12 - export const DEFAULT_SORT = 'updated'; 13 - 14 - /** 15 - * Compare two documents by the given sort criteria. 16 - * Each doc has: { id, type, name_encrypted, created_at, updated_at, _decryptedName? } 17 - * _decryptedName is populated at render time; absent means "Encrypted Document". 18 - * 19 - * Returns negative if a < b, positive if a > b, 0 if equal. 20 - */ 21 - export function compareDocuments(a, b, sortBy) { 22 - switch (sortBy) { 23 - case 'name': { 24 - const nameA = (a._decryptedName || 'Encrypted Document').toLowerCase(); 25 - const nameB = (b._decryptedName || 'Encrypted Document').toLowerCase(); 26 - return nameA.localeCompare(nameB); 27 - } 28 - case 'created': { 29 - // Newest first 30 - const ca = a.created_at || ''; 31 - const cb = b.created_at || ''; 32 - return cb.localeCompare(ca); 33 - } 34 - case 'type': { 35 - const cmp = (a.type || '').localeCompare(b.type || ''); 36 - if (cmp !== 0) return cmp; 37 - // Secondary sort: updated descending 38 - return (b.updated_at || '').localeCompare(a.updated_at || ''); 39 - } 40 - case 'updated': 41 - default: { 42 - const ua = a.updated_at || ''; 43 - const ub = b.updated_at || ''; 44 - return ub.localeCompare(ua); 45 - } 46 - } 47 - } 48 - 49 - /** 50 - * Sort documents with starred items first, then by the given criteria. 51 - * @param {Array} docs - array of document objects (with _decryptedName populated) 52 - * @param {string} sortBy - one of SORT_OPTIONS 53 - * @param {Set<string>} starredIds - set of starred document IDs 54 - * @returns {Array} sorted copy of the array 55 - */ 56 - export function sortDocuments(docs, sortBy, starredIds = new Set()) { 57 - return [...docs].sort((a, b) => { 58 - const aStarred = starredIds.has(a.id); 59 - const bStarred = starredIds.has(b.id); 60 - if (aStarred && !bStarred) return -1; 61 - if (!aStarred && bStarred) return 1; 62 - return compareDocuments(a, b, sortBy); 63 - }); 64 - } 65 - 66 - // ============================================================ 67 - // Stars / Favorites 68 - // ============================================================ 69 - 70 - /** 71 - * Toggle a document's starred state. 72 - * @param {Object} stars - { [docId]: true } map 73 - * @param {string} docId 74 - * @returns {Object} new stars map (immutable) 75 - */ 76 - export function toggleStar(stars, docId) { 77 - const next = { ...stars }; 78 - if (next[docId]) { 79 - delete next[docId]; 80 - } else { 81 - next[docId] = true; 82 - } 83 - return next; 84 - } 85 - 86 - /** 87 - * Get the set of starred IDs from a stars map. 88 - * @param {Object} stars - { [docId]: true } 89 - * @returns {Set<string>} 90 - */ 91 - export function starredIdsSet(stars) { 92 - return new Set(Object.keys(stars || {})); 93 - } 94 - 95 - // ============================================================ 96 - // Trash / Soft Delete 97 - // ============================================================ 98 - 99 - /** 100 - * @typedef {Object} TrashEntry 101 - * @property {string} id - document ID 102 - * @property {number} deletedAt - timestamp (ms since epoch) 103 - */ 104 - 105 - const TRASH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days 106 - 107 - /** 108 - * Add a document to the trash. 109 - * @param {TrashEntry[]} trash - current trash array 110 - * @param {string} docId 111 - * @param {number} [now] - current timestamp (for testing) 112 - * @returns {TrashEntry[]} new trash array 113 - */ 114 - export function addToTrash(trash, docId, now = Date.now()) { 115 - // Don't add duplicates 116 - if (trash.some(t => t.id === docId)) return trash; 117 - return [...trash, { id: docId, deletedAt: now }]; 118 - } 119 - 120 - /** 121 - * Restore a document from trash. 122 - * @param {TrashEntry[]} trash 123 - * @param {string} docId 124 - * @returns {TrashEntry[]} new trash array without the doc 125 - */ 126 - export function restoreFromTrash(trash, docId) { 127 - return trash.filter(t => t.id !== docId); 128 - } 129 - 130 - /** 131 - * Remove expired items from trash (older than 30 days). 132 - * Returns { kept, expired } where expired are the IDs that should be permanently deleted. 133 - * @param {TrashEntry[]} trash 134 - * @param {number} [now] 135 - * @returns {{ kept: TrashEntry[], expired: string[] }} 136 - */ 137 - export function purgeExpiredTrash(trash, now = Date.now()) { 138 - const kept = []; 139 - const expired = []; 140 - for (const entry of trash) { 141 - if (now - entry.deletedAt >= TRASH_TTL_MS) { 142 - expired.push(entry.id); 143 - } else { 144 - kept.push(entry); 145 - } 146 - } 147 - return { kept, expired }; 148 - } 149 - 150 - /** 151 - * Check if a document is in the trash. 152 - * @param {TrashEntry[]} trash 153 - * @param {string} docId 154 - * @returns {boolean} 155 - */ 156 - export function isInTrash(trash, docId) { 157 - return trash.some(t => t.id === docId); 158 - } 159 - 160 - /** 161 - * Permanently remove a document from trash (for permanent delete). 162 - * Same as restoreFromTrash in terms of data structure update, but semantically different. 163 - * @param {TrashEntry[]} trash 164 - * @param {string} docId 165 - * @returns {TrashEntry[]} 166 - */ 167 - export function removeFromTrash(trash, docId) { 168 - return trash.filter(t => t.id !== docId); 169 - } 170 - 171 - /** 172 - * Partition documents into active and trashed. 173 - * @param {Array} docs - all documents from server 174 - * @param {TrashEntry[]} trash 175 - * @returns {{ active: Array, trashed: Array }} 176 - */ 177 - export function partitionDocuments(docs, trash) { 178 - const trashedIds = new Set(trash.map(t => t.id)); 179 - const active = []; 180 - const trashed = []; 181 - for (const doc of docs) { 182 - if (trashedIds.has(doc.id)) { 183 - trashed.push(doc); 184 - } else { 185 - active.push(doc); 186 - } 187 - } 188 - return { active, trashed }; 189 - } 190 - 191 - // ============================================================ 192 - // Folders 193 - // ============================================================ 194 - 195 - /** 196 - * @typedef {Object} Folder 197 - * @property {string} id 198 - * @property {string} name 199 - * @property {number} createdAt 200 - */ 201 - 202 - /** 203 - * Create a new folder. 204 - * @param {Folder[]} folders 205 - * @param {string} name 206 - * @param {string} [id] - auto-generated if not provided 207 - * @returns {Folder[]} 208 - */ 209 - export function createFolder(folders, name, id = generateFolderId()) { 210 - return [...folders, { id, name, createdAt: Date.now() }]; 211 - } 212 - 213 - /** 214 - * Rename a folder. 215 - * @param {Folder[]} folders 216 - * @param {string} folderId 217 - * @param {string} newName 218 - * @returns {Folder[]} 219 - */ 220 - export function renameFolder(folders, folderId, newName) { 221 - return folders.map(f => f.id === folderId ? { ...f, name: newName } : f); 222 - } 223 - 224 - /** 225 - * Delete a folder. Does NOT remove documents from the folder — caller must 226 - * also clear the folder assignments. 227 - * @param {Folder[]} folders 228 - * @param {string} folderId 229 - * @returns {Folder[]} 230 - */ 231 - export function deleteFolder(folders, folderId) { 232 - return folders.filter(f => f.id !== folderId); 233 - } 234 - 235 - /** 236 - * Move a document into a folder (or out of one). 237 - * @param {Object} assignments - { [docId]: folderId } 238 - * @param {string} docId 239 - * @param {string|null} folderId - null to remove from folder 240 - * @returns {Object} new assignments map 241 - */ 242 - export function moveToFolder(assignments, docId, folderId) { 243 - const next = { ...assignments }; 244 - if (folderId === null || folderId === undefined) { 245 - delete next[docId]; 246 - } else { 247 - next[docId] = folderId; 248 - } 249 - return next; 250 - } 251 - 252 - /** 253 - * Get documents in a specific folder (or unassigned if folderId is null). 254 - * @param {Array} docs 255 - * @param {Object} assignments - { [docId]: folderId } 256 - * @param {string|null} folderId - null means "unassigned / root" 257 - * @returns {Array} 258 - */ 259 - export function getDocsInFolder(docs, assignments, folderId) { 260 - if (folderId === null || folderId === undefined) { 261 - // Root: docs not assigned to any folder 262 - const assignedIds = new Set(Object.keys(assignments)); 263 - return docs.filter(d => !assignedIds.has(d.id)); 264 - } 265 - return docs.filter(d => assignments[d.id] === folderId); 266 - } 267 - 268 - /** 269 - * Build breadcrumb navigation path. 270 - * For now, folders are flat (no nesting), so breadcrumb is simply: 271 - * [{ id: null, name: 'All Documents' }, { id: folderId, name: folderName }] 272 - * @param {Folder[]} folders 273 - * @param {string|null} currentFolderId 274 - * @returns {Array<{ id: string|null, name: string }>} 275 - */ 276 - export function buildBreadcrumbs(folders, currentFolderId) { 277 - const crumbs = [{ id: null, name: 'All Documents' }]; 278 - if (currentFolderId) { 279 - const folder = folders.find(f => f.id === currentFolderId); 280 - if (folder) { 281 - crumbs.push({ id: folder.id, name: folder.name }); 282 - } 283 - } 284 - return crumbs; 285 - } 286 - 287 - /** 288 - * Remove all folder assignments for a given folder (when folder is deleted). 289 - * @param {Object} assignments 290 - * @param {string} folderId 291 - * @returns {Object} 292 - */ 293 - export function clearFolderAssignments(assignments, folderId) { 294 - const next = {}; 295 - for (const [docId, fId] of Object.entries(assignments)) { 296 - if (fId !== folderId) { 297 - next[docId] = fId; 298 - } 299 - } 300 - return next; 301 - } 302 - 303 - /** 304 - * Generate a simple folder ID. 305 - */ 306 - export function generateFolderId() { 307 - return 'folder-' + Math.random().toString(36).slice(2, 10); 308 - } 309 - 310 - // ============================================================ 311 - // Search 312 - // ============================================================ 313 - 314 - /** 315 - * Filter documents by search query (matched against decrypted name). 316 - * @param {Array} docs - documents with _decryptedName populated 317 - * @param {string} query 318 - * @returns {Array} 319 - */ 320 - export function filterBySearch(docs, query) { 321 - if (!query || !query.trim()) return docs; 322 - const q = query.trim().toLowerCase(); 323 - return docs.filter(doc => { 324 - const name = (doc._decryptedName || 'Encrypted Document').toLowerCase(); 325 - return name.includes(q); 326 - }); 327 - } 328 - 329 - // ============================================================ 330 - // Username 331 - // ============================================================ 332 - 333 - /** 334 - * Generate a random username like "User 1234". 335 - * @returns {string} 336 - */ 337 - export function generateRandomUsername() { 338 - const num = Math.floor(1000 + Math.random() * 9000); 339 - return `User ${num}`; 340 - } 341 - 342 - /** 343 - * Validate a username. 344 - * @param {string} name 345 - * @returns {{ valid: boolean, error?: string }} 346 - */ 347 - export function validateUsername(name) { 348 - if (!name || !name.trim()) { 349 - return { valid: false, error: 'Name cannot be empty' }; 350 - } 351 - const trimmed = name.trim(); 352 - if (trimmed.length > 50) { 353 - return { valid: false, error: 'Name must be 50 characters or less' }; 354 - } 355 - return { valid: true }; 356 - }
+286
src/landing-utils.ts
··· 1 + /** 2 + * Pure utility functions for the landing page features. 3 + * Extracted for testability — no DOM or localStorage access here. 4 + */ 5 + import type { 6 + DocumentMeta, 7 + SortField, 8 + Folder, 9 + TrashEntry, 10 + FolderAssignments, 11 + StarMap, 12 + Breadcrumb, 13 + UsernameValidationResult, 14 + PurgeResult, 15 + } from './landing-types.js'; 16 + 17 + // ============================================================ 18 + // Sort 19 + // ============================================================ 20 + 21 + /** Available sort criteria. */ 22 + export const SORT_OPTIONS: SortField[] = ['updated', 'created', 'name', 'type']; 23 + export const DEFAULT_SORT: SortField = 'updated'; 24 + 25 + /** 26 + * Compare two documents by the given sort criteria. 27 + */ 28 + export function compareDocuments(a: DocumentMeta, b: DocumentMeta, sortBy: string): number { 29 + switch (sortBy) { 30 + case 'name': { 31 + const nameA = (a._decryptedName || 'Encrypted Document').toLowerCase(); 32 + const nameB = (b._decryptedName || 'Encrypted Document').toLowerCase(); 33 + return nameA.localeCompare(nameB); 34 + } 35 + case 'created': { 36 + // Newest first 37 + const ca = a.created_at || ''; 38 + const cb = b.created_at || ''; 39 + return cb.localeCompare(ca); 40 + } 41 + case 'type': { 42 + const cmp = (a.type || '').localeCompare(b.type || ''); 43 + if (cmp !== 0) return cmp; 44 + // Secondary sort: updated descending 45 + return (b.updated_at || '').localeCompare(a.updated_at || ''); 46 + } 47 + case 'updated': 48 + default: { 49 + const ua = a.updated_at || ''; 50 + const ub = b.updated_at || ''; 51 + return ub.localeCompare(ua); 52 + } 53 + } 54 + } 55 + 56 + /** 57 + * Sort documents with starred items first, then by the given criteria. 58 + */ 59 + export function sortDocuments(docs: DocumentMeta[], sortBy: string, starredIds: Set<string> = new Set()): DocumentMeta[] { 60 + return [...docs].sort((a, b) => { 61 + const aStarred = starredIds.has(a.id); 62 + const bStarred = starredIds.has(b.id); 63 + if (aStarred && !bStarred) return -1; 64 + if (!aStarred && bStarred) return 1; 65 + return compareDocuments(a, b, sortBy); 66 + }); 67 + } 68 + 69 + // ============================================================ 70 + // Stars / Favorites 71 + // ============================================================ 72 + 73 + /** 74 + * Toggle a document's starred state. 75 + */ 76 + export function toggleStar(stars: StarMap, docId: string): StarMap { 77 + const next = { ...stars }; 78 + if (next[docId]) { 79 + delete next[docId]; 80 + } else { 81 + next[docId] = true; 82 + } 83 + return next; 84 + } 85 + 86 + /** 87 + * Get the set of starred IDs from a stars map. 88 + */ 89 + export function starredIdsSet(stars: StarMap | null | undefined): Set<string> { 90 + return new Set(Object.keys(stars || {})); 91 + } 92 + 93 + // ============================================================ 94 + // Trash / Soft Delete 95 + // ============================================================ 96 + 97 + const TRASH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days 98 + 99 + /** 100 + * Add a document to the trash. 101 + */ 102 + export function addToTrash(trash: TrashEntry[], docId: string, now: number = Date.now()): TrashEntry[] { 103 + // Don't add duplicates 104 + if (trash.some(t => t.id === docId)) return trash; 105 + return [...trash, { id: docId, deletedAt: now }]; 106 + } 107 + 108 + /** 109 + * Restore a document from trash. 110 + */ 111 + export function restoreFromTrash(trash: TrashEntry[], docId: string): TrashEntry[] { 112 + return trash.filter(t => t.id !== docId); 113 + } 114 + 115 + /** 116 + * Remove expired items from trash (older than 30 days). 117 + */ 118 + export function purgeExpiredTrash(trash: TrashEntry[], now: number = Date.now()): PurgeResult { 119 + const kept: TrashEntry[] = []; 120 + const expired: string[] = []; 121 + for (const entry of trash) { 122 + if (now - entry.deletedAt >= TRASH_TTL_MS) { 123 + expired.push(entry.id); 124 + } else { 125 + kept.push(entry); 126 + } 127 + } 128 + return { kept, expired }; 129 + } 130 + 131 + /** 132 + * Check if a document is in the trash. 133 + */ 134 + export function isInTrash(trash: TrashEntry[], docId: string): boolean { 135 + return trash.some(t => t.id === docId); 136 + } 137 + 138 + /** 139 + * Permanently remove a document from trash. 140 + */ 141 + export function removeFromTrash(trash: TrashEntry[], docId: string): TrashEntry[] { 142 + return trash.filter(t => t.id !== docId); 143 + } 144 + 145 + /** 146 + * Partition documents into active and trashed. 147 + */ 148 + export function partitionDocuments(docs: DocumentMeta[], trash: TrashEntry[]): { active: DocumentMeta[]; trashed: DocumentMeta[] } { 149 + const trashedIds = new Set(trash.map(t => t.id)); 150 + const active: DocumentMeta[] = []; 151 + const trashed: DocumentMeta[] = []; 152 + for (const doc of docs) { 153 + if (trashedIds.has(doc.id)) { 154 + trashed.push(doc); 155 + } else { 156 + active.push(doc); 157 + } 158 + } 159 + return { active, trashed }; 160 + } 161 + 162 + // ============================================================ 163 + // Folders 164 + // ============================================================ 165 + 166 + /** 167 + * Create a new folder. 168 + */ 169 + export function createFolder(folders: Folder[], name: string, id: string = generateFolderId()): Folder[] { 170 + return [...folders, { id, name, createdAt: Date.now() }]; 171 + } 172 + 173 + /** 174 + * Rename a folder. 175 + */ 176 + export function renameFolder(folders: Folder[], folderId: string, newName: string): Folder[] { 177 + return folders.map(f => f.id === folderId ? { ...f, name: newName } : f); 178 + } 179 + 180 + /** 181 + * Delete a folder. 182 + */ 183 + export function deleteFolder(folders: Folder[], folderId: string): Folder[] { 184 + return folders.filter(f => f.id !== folderId); 185 + } 186 + 187 + /** 188 + * Move a document into a folder (or out of one). 189 + */ 190 + export function moveToFolder(assignments: FolderAssignments, docId: string, folderId: string | null | undefined): FolderAssignments { 191 + const next = { ...assignments }; 192 + if (folderId === null || folderId === undefined) { 193 + delete next[docId]; 194 + } else { 195 + next[docId] = folderId; 196 + } 197 + return next; 198 + } 199 + 200 + /** 201 + * Get documents in a specific folder (or unassigned if folderId is null). 202 + */ 203 + export function getDocsInFolder(docs: DocumentMeta[], assignments: FolderAssignments, folderId: string | null | undefined): DocumentMeta[] { 204 + if (folderId === null || folderId === undefined) { 205 + // Root: docs not assigned to any folder 206 + const assignedIds = new Set(Object.keys(assignments)); 207 + return docs.filter(d => !assignedIds.has(d.id)); 208 + } 209 + return docs.filter(d => assignments[d.id] === folderId); 210 + } 211 + 212 + /** 213 + * Build breadcrumb navigation path. 214 + */ 215 + export function buildBreadcrumbs(folders: Folder[], currentFolderId: string | null): Breadcrumb[] { 216 + const crumbs: Breadcrumb[] = [{ id: null, name: 'All Documents' }]; 217 + if (currentFolderId) { 218 + const folder = folders.find(f => f.id === currentFolderId); 219 + if (folder) { 220 + crumbs.push({ id: folder.id, name: folder.name }); 221 + } 222 + } 223 + return crumbs; 224 + } 225 + 226 + /** 227 + * Remove all folder assignments for a given folder. 228 + */ 229 + export function clearFolderAssignments(assignments: FolderAssignments, folderId: string): FolderAssignments { 230 + const next: FolderAssignments = {}; 231 + for (const [docId, fId] of Object.entries(assignments)) { 232 + if (fId !== folderId) { 233 + next[docId] = fId; 234 + } 235 + } 236 + return next; 237 + } 238 + 239 + /** 240 + * Generate a simple folder ID. 241 + */ 242 + export function generateFolderId(): string { 243 + return 'folder-' + Math.random().toString(36).slice(2, 10); 244 + } 245 + 246 + // ============================================================ 247 + // Search 248 + // ============================================================ 249 + 250 + /** 251 + * Filter documents by search query (matched against decrypted name). 252 + */ 253 + export function filterBySearch(docs: DocumentMeta[], query: string | null | undefined): DocumentMeta[] { 254 + if (!query || !query.trim()) return docs; 255 + const q = query.trim().toLowerCase(); 256 + return docs.filter(doc => { 257 + const name = (doc._decryptedName || 'Encrypted Document').toLowerCase(); 258 + return name.includes(q); 259 + }); 260 + } 261 + 262 + // ============================================================ 263 + // Username 264 + // ============================================================ 265 + 266 + /** 267 + * Generate a random username like "User 1234". 268 + */ 269 + export function generateRandomUsername(): string { 270 + const num = Math.floor(1000 + Math.random() * 9000); 271 + return `User ${num}`; 272 + } 273 + 274 + /** 275 + * Validate a username. 276 + */ 277 + export function validateUsername(name: string): UsernameValidationResult { 278 + if (!name || !name.trim()) { 279 + return { valid: false, error: 'Name cannot be empty' }; 280 + } 281 + const trimmed = name.trim(); 282 + if (trimmed.length > 50) { 283 + return { valid: false, error: 'Name must be 50 characters or less' }; 284 + } 285 + return { valid: true }; 286 + }
+49 -48
src/landing.js src/landing.ts
··· 1 + import type { DocumentMeta, TrashEntry, Folder, FolderAssignments, StarMap, SortLabels } from './landing-types.js'; 1 2 import { generateKey, exportKey, importKey, decryptString } from './lib/crypto.js'; 2 3 import { 3 4 sortDocuments, ··· 28 29 } from './landing-dragdrop.js'; 29 30 30 31 // --- DOM refs --- 31 - const docListEl = document.getElementById('doc-list'); 32 - const folderListEl = document.getElementById('folder-list'); 33 - const noResultsEl = document.getElementById('no-results'); 34 - const newDocBtn = document.getElementById('new-doc'); 35 - const newSheetBtn = document.getElementById('new-sheet'); 36 - const searchInput = document.getElementById('search-input'); 37 - const searchClear = document.getElementById('search-clear'); 38 - const sortBtn = document.getElementById('sort-btn'); 39 - const sortLabel = document.getElementById('sort-label'); 40 - const sortMenu = document.getElementById('sort-menu'); 41 - const newFolderBtn = document.getElementById('new-folder-btn'); 42 - const breadcrumbsEl = document.getElementById('breadcrumbs'); 43 - const trashSection = document.getElementById('trash-section'); 44 - const trashToggle = document.getElementById('trash-toggle'); 45 - const trashCount = document.getElementById('trash-count'); 46 - const trashListEl = document.getElementById('trash-list'); 32 + const docListEl = document.getElementById('doc-list') as HTMLElement; 33 + const folderListEl = document.getElementById('folder-list') as HTMLElement; 34 + const noResultsEl = document.getElementById('no-results') as HTMLElement; 35 + const newDocBtn = document.getElementById('new-doc') as HTMLElement; 36 + const newSheetBtn = document.getElementById('new-sheet') as HTMLElement; 37 + const searchInput = document.getElementById('search-input') as HTMLInputElement; 38 + const searchClear = document.getElementById('search-clear') as HTMLElement; 39 + const sortBtn = document.getElementById('sort-btn') as HTMLElement; 40 + const sortLabel = document.getElementById('sort-label') as HTMLElement; 41 + const sortMenu = document.getElementById('sort-menu') as HTMLElement; 42 + const newFolderBtn = document.getElementById('new-folder-btn') as HTMLElement; 43 + const breadcrumbsEl = document.getElementById('breadcrumbs') as HTMLElement; 44 + const trashSection = document.getElementById('trash-section') as HTMLElement; 45 + const trashToggle = document.getElementById('trash-toggle') as HTMLElement; 46 + const trashCount = document.getElementById('trash-count') as HTMLElement; 47 + const trashListEl = document.getElementById('trash-list') as HTMLElement; 47 48 48 49 // Modals 49 - const usernameModal = document.getElementById('username-modal'); 50 - const usernameInput = document.getElementById('username-input'); 51 - const usernameSkip = document.getElementById('username-skip'); 52 - const usernameConfirm = document.getElementById('username-confirm'); 53 - const folderModal = document.getElementById('folder-modal'); 54 - const folderModalTitle = document.getElementById('folder-modal-title'); 55 - const folderNameInput = document.getElementById('folder-name-input'); 56 - const folderCancel = document.getElementById('folder-cancel'); 57 - const folderConfirm = document.getElementById('folder-confirm'); 58 - const moveModal = document.getElementById('move-modal'); 59 - const moveFolderList = document.getElementById('move-folder-list'); 60 - const moveCancel = document.getElementById('move-cancel'); 61 - const userBadge = document.getElementById('user-badge'); 50 + const usernameModal = document.getElementById('username-modal') as HTMLElement; 51 + const usernameInput = document.getElementById('username-input') as HTMLInputElement; 52 + const usernameSkip = document.getElementById('username-skip') as HTMLElement; 53 + const usernameConfirm = document.getElementById('username-confirm') as HTMLElement; 54 + const folderModal = document.getElementById('folder-modal') as HTMLElement; 55 + const folderModalTitle = document.getElementById('folder-modal-title') as HTMLElement; 56 + const folderNameInput = document.getElementById('folder-name-input') as HTMLInputElement; 57 + const folderCancel = document.getElementById('folder-cancel') as HTMLElement; 58 + const folderConfirm = document.getElementById('folder-confirm') as HTMLElement; 59 + const moveModal = document.getElementById('move-modal') as HTMLElement; 60 + const moveFolderList = document.getElementById('move-folder-list') as HTMLElement; 61 + const moveCancel = document.getElementById('move-cancel') as HTMLElement; 62 + const userBadge = document.getElementById('user-badge') as HTMLElement; 62 63 63 64 // --- State --- 64 - let allDocs = []; // raw from server, with _decryptedName populated 65 - let currentSort = localStorage.getItem('tools-sort') || DEFAULT_SORT; 66 - let stars = JSON.parse(localStorage.getItem('tools-stars') || '{}'); 67 - let trash = JSON.parse(localStorage.getItem('tools-trash') || '[]'); 68 - let folders = JSON.parse(localStorage.getItem('tools-folders') || '[]'); 69 - let folderAssignments = JSON.parse(localStorage.getItem('tools-folder-assignments') || '{}'); 70 - let currentFolderId = null; // null = root / All Documents 65 + let allDocs: DocumentMeta[] = []; // raw from server, with _decryptedName populated 66 + let currentSort: string = localStorage.getItem('tools-sort') || DEFAULT_SORT; 67 + let stars: StarMap = JSON.parse(localStorage.getItem('tools-stars') || '{}'); 68 + let trash: TrashEntry[] = JSON.parse(localStorage.getItem('tools-trash') || '[]'); 69 + let folders: Folder[] = JSON.parse(localStorage.getItem('tools-folders') || '[]'); 70 + let folderAssignments: FolderAssignments = JSON.parse(localStorage.getItem('tools-folder-assignments') || '{}'); 71 + let currentFolderId: string | null = null; // null = root / All Documents 71 72 let searchQuery = ''; 72 73 let trashExpanded = false; 73 74 74 75 // Folder modal state 75 - let folderModalMode = 'create'; // 'create' | 'rename' 76 - let folderModalTargetId = null; 76 + let folderModalMode: 'create' | 'rename' = 'create'; 77 + let folderModalTargetId: string | null = null; 77 78 78 79 // Move modal state 79 - let moveDocId = null; 80 + let moveDocId: string | null = null; 80 81 81 82 // --- Migrate legacy localStorage keys --- 82 83 if (localStorage.getItem('crypt-keys') && !localStorage.getItem('tools-keys')) { ··· 89 90 } 90 91 91 92 // --- Sort labels --- 92 - const SORT_LABELS = { 93 + const SORT_LABELS: SortLabels = { 93 94 updated: 'Last updated', 94 95 created: 'Created', 95 96 name: 'Name', ··· 107 108 usernameInput.focus(); 108 109 } 109 110 110 - function showUserBadge(name) { 111 + function showUserBadge(name: string): void { 111 112 userBadge.textContent = name; 112 113 userBadge.style.display = ''; 113 114 } 114 115 115 - function saveUsername(name) { 116 + function saveUsername(name: string): void { 116 117 localStorage.setItem('tools-username', name); 117 118 usernameModal.style.display = 'none'; 118 119 showUserBadge(name); ··· 151 152 }); 152 153 153 154 // --- Create document --- 154 - async function createDocument(type) { 155 + async function createDocument(type: 'doc' | 'sheet'): Promise<void> { 155 156 const key = await generateKey(); 156 157 const keyStr = await exportKey(key); 157 158 ··· 441 442 }); 442 443 } 443 444 444 - function renderFolders(activeDocs) { 445 + function renderFolders(activeDocs: DocumentMeta[]): void { 445 446 // Only show folders at root level and when not searching 446 447 if (currentFolderId !== null || searchQuery) { 447 448 folderListEl.innerHTML = ''; ··· 512 513 }); 513 514 } 514 515 515 - function renderTrash(trashedDocs, keys) { 516 + function renderTrash(trashedDocs: DocumentMeta[], keys: Record<string, string>): void { 516 517 if (trashedDocs.length === 0) { 517 518 trashSection.style.display = 'none'; 518 519 return; ··· 577 578 }); 578 579 } 579 580 580 - function showMoveModal(docId) { 581 + function showMoveModal(docId: string): void { 581 582 moveDocId = docId; 582 583 const currentFolder = folderAssignments[docId] || null; 583 584 ··· 612 613 }); 613 614 } 614 615 615 - function escapeHtml(text) { 616 + function escapeHtml(text: string): string { 616 617 const div = document.createElement('div'); 617 618 div.textContent = text; 618 619 return div.innerHTML; ··· 639 640 dropOverlay.style.display = 'none'; 640 641 } 641 642 642 - function showToast(message, duration = 3000, isError = false) { 643 + function showToast(message: string, duration = 3000, isError = false): void { 643 644 const existing = document.querySelector('.toast-notification'); 644 645 if (existing) existing.remove(); 645 646 const toast = document.createElement('div');
-292
src/lib/context-menu.js
··· 1 - /** 2 - * Context Menu — shared component for Docs and Sheets. 3 - * 4 - * Renders a custom right-click context menu with: 5 - * - Icon, label, shortcut hint per item 6 - * - Separator support 7 - * - Disabled state 8 - * - Keyboard navigation (arrow keys, Enter, Escape) 9 - * - Auto-positioning near screen edges 10 - */ 11 - 12 - /** Sentinel value used as a separator between item groups. */ 13 - export const SEPARATOR = Object.freeze({ type: 'separator' }); 14 - 15 - /** 16 - * Position a context menu so it stays within the viewport. 17 - * 18 - * @param {number} mouseX - Click X coordinate 19 - * @param {number} mouseY - Click Y coordinate 20 - * @param {number} menuW - Menu element width 21 - * @param {number} menuH - Menu element height 22 - * @param {number} vpW - Viewport width 23 - * @param {number} vpH - Viewport height 24 - * @returns {{ x: number, y: number }} 25 - */ 26 - export function positionMenu(mouseX, mouseY, menuW, menuH, vpW, vpH) { 27 - let x = mouseX; 28 - let y = mouseY; 29 - 30 - if (x + menuW > vpW) x = vpW - menuW; 31 - if (y + menuH > vpH) y = vpH - menuH; 32 - if (x < 0) x = 0; 33 - if (y < 0) y = 0; 34 - 35 - return { x, y }; 36 - } 37 - 38 - /** 39 - * Create a context menu DOM element from a list of menu items. 40 - * 41 - * Each item is either SEPARATOR or an object: 42 - * { label: string, action: () => void, icon?: string, shortcut?: string, disabled?: boolean } 43 - * 44 - * @param {Array} items 45 - * @returns {{ el: HTMLElement, show: (x: number, y: number) => void, hide: () => void, destroy: () => void }} 46 - */ 47 - export function createContextMenu(items) { 48 - const menu = document.createElement('div'); 49 - menu.className = 'context-menu'; 50 - menu.setAttribute('role', 'menu'); 51 - menu.style.display = 'none'; 52 - menu.style.position = 'fixed'; 53 - menu.style.zIndex = '9999'; 54 - 55 - for (const item of items) { 56 - if (item === SEPARATOR || (item && item.type === 'separator')) { 57 - const sep = document.createElement('div'); 58 - sep.className = 'context-menu-separator'; 59 - sep.setAttribute('role', 'separator'); 60 - menu.appendChild(sep); 61 - continue; 62 - } 63 - 64 - const btn = document.createElement('button'); 65 - btn.className = 'context-menu-item'; 66 - btn.setAttribute('role', 'menuitem'); 67 - btn.setAttribute('tabindex', '0'); 68 - 69 - if (item.disabled) { 70 - btn.classList.add('disabled'); 71 - btn.setAttribute('aria-disabled', 'true'); 72 - } 73 - 74 - // Icon 75 - if (item.icon) { 76 - const iconSpan = document.createElement('span'); 77 - iconSpan.className = 'context-menu-icon'; 78 - iconSpan.textContent = item.icon; 79 - btn.appendChild(iconSpan); 80 - } 81 - 82 - // Label 83 - const labelSpan = document.createElement('span'); 84 - labelSpan.className = 'context-menu-label'; 85 - labelSpan.textContent = item.label; 86 - btn.appendChild(labelSpan); 87 - 88 - // Shortcut hint 89 - if (item.shortcut) { 90 - const shortcutSpan = document.createElement('span'); 91 - shortcutSpan.className = 'context-menu-shortcut'; 92 - shortcutSpan.textContent = item.shortcut; 93 - btn.appendChild(shortcutSpan); 94 - } 95 - 96 - // Click handler 97 - btn.addEventListener('click', (e) => { 98 - e.stopPropagation(); 99 - if (item.disabled) return; 100 - item.action(); 101 - hide(); 102 - }); 103 - 104 - // Keyboard: Enter to activate 105 - btn.addEventListener('keydown', (e) => { 106 - if (e.key === 'Enter') { 107 - e.preventDefault(); 108 - if (!item.disabled) { 109 - item.action(); 110 - hide(); 111 - } 112 - } 113 - }); 114 - 115 - menu.appendChild(btn); 116 - } 117 - 118 - // Keyboard navigation on the menu container 119 - menu.addEventListener('keydown', (e) => { 120 - const focusable = Array.from(menu.querySelectorAll('.context-menu-item:not(.disabled)')); 121 - const current = document.activeElement; 122 - const idx = focusable.indexOf(current); 123 - 124 - if (e.key === 'ArrowDown') { 125 - e.preventDefault(); 126 - const next = idx < focusable.length - 1 ? idx + 1 : 0; 127 - focusable[next]?.focus(); 128 - } else if (e.key === 'ArrowUp') { 129 - e.preventDefault(); 130 - const prev = idx > 0 ? idx - 1 : focusable.length - 1; 131 - focusable[prev]?.focus(); 132 - } else if (e.key === 'Escape') { 133 - e.preventDefault(); 134 - hide(); 135 - } 136 - }); 137 - 138 - function show(x, y) { 139 - menu.style.display = 'block'; 140 - menu.style.left = x + 'px'; 141 - menu.style.top = y + 'px'; 142 - 143 - // Focus first item for keyboard navigation 144 - const first = menu.querySelector('.context-menu-item:not(.disabled)'); 145 - if (first) first.focus(); 146 - } 147 - 148 - function hide() { 149 - menu.style.display = 'none'; 150 - } 151 - 152 - function destroy() { 153 - if (menu.parentNode) { 154 - menu.parentNode.removeChild(menu); 155 - } 156 - } 157 - 158 - return { el: menu, show, hide, destroy }; 159 - } 160 - 161 - // ---- Docs context menu item builders ---- 162 - 163 - /** 164 - * Build menu items for right-clicking on text in Docs. 165 - * Actions are no-ops here; the consumer wires them to the editor. 166 - */ 167 - export function buildDocsTextItems() { 168 - return [ 169 - { label: 'Cut', icon: '✂', shortcut: '⌘X', action: () => {} }, 170 - { label: 'Copy', icon: '⧉', shortcut: '⌘C', action: () => {} }, 171 - { label: 'Paste', icon: '📋', shortcut: '⌘V', action: () => {} }, 172 - { label: 'Select All', shortcut: '⌘A', action: () => {} }, 173 - SEPARATOR, 174 - { label: 'Bold', icon: 'B', shortcut: '⌘B', action: () => {} }, 175 - { label: 'Italic', icon: 'I', shortcut: '⌘I', action: () => {} }, 176 - { label: 'Underline', icon: 'U', shortcut: '⌘U', action: () => {} }, 177 - SEPARATOR, 178 - { label: 'Link', icon: '🔗', shortcut: '⌘K', action: () => {} }, 179 - { label: 'Comment', icon: '💬', action: () => {} }, 180 - ]; 181 - } 182 - 183 - /** 184 - * Build menu items for right-clicking on a link in Docs. 185 - */ 186 - export function buildDocsLinkItems() { 187 - return [ 188 - { label: 'Open Link', icon: '↗', action: () => {} }, 189 - { label: 'Edit Link', icon: '✏', action: () => {} }, 190 - { label: 'Remove Link', icon: '✕', action: () => {} }, 191 - SEPARATOR, 192 - { label: 'Copy', icon: '⧉', shortcut: '⌘C', action: () => {} }, 193 - ]; 194 - } 195 - 196 - /** 197 - * Build menu items for right-clicking on an image in Docs. 198 - */ 199 - export function buildDocsImageItems() { 200 - return [ 201 - { label: 'Image Properties', icon: '⚙', action: () => {} }, 202 - SEPARATOR, 203 - { label: 'Cut', icon: '✂', shortcut: '⌘X', action: () => {} }, 204 - { label: 'Copy', icon: '⧉', shortcut: '⌘C', action: () => {} }, 205 - ]; 206 - } 207 - 208 - /** 209 - * Build menu items for right-clicking on a table in Docs. 210 - */ 211 - export function buildDocsTableItems() { 212 - return [ 213 - { label: 'Insert Row Above', action: () => {} }, 214 - { label: 'Insert Row Below', action: () => {} }, 215 - { label: 'Insert Column Left', action: () => {} }, 216 - { label: 'Insert Column Right', action: () => {} }, 217 - SEPARATOR, 218 - { label: 'Delete Row', action: () => {} }, 219 - { label: 'Delete Column', action: () => {} }, 220 - SEPARATOR, 221 - { label: 'Cut', icon: '✂', shortcut: '⌘X', action: () => {} }, 222 - { label: 'Copy', icon: '⧉', shortcut: '⌘C', action: () => {} }, 223 - { label: 'Paste', icon: '📋', shortcut: '⌘V', action: () => {} }, 224 - ]; 225 - } 226 - 227 - // ---- Sheets context menu item builders ---- 228 - 229 - /** 230 - * Build menu items for right-clicking on a cell in Sheets. 231 - */ 232 - export function buildSheetsCellItems() { 233 - return [ 234 - { label: 'Cut', icon: '✂', shortcut: '⌘X', action: () => {} }, 235 - { label: 'Copy', icon: '⧉', shortcut: '⌘C', action: () => {} }, 236 - { label: 'Paste', icon: '📋', shortcut: '⌘V', action: () => {} }, 237 - { label: 'Paste Special', icon: '📋', action: () => {} }, 238 - SEPARATOR, 239 - { label: 'Insert Row Above', action: () => {} }, 240 - { label: 'Insert Row Below', action: () => {} }, 241 - { label: 'Insert Column Left', action: () => {} }, 242 - { label: 'Insert Column Right', action: () => {} }, 243 - SEPARATOR, 244 - { label: 'Delete Row', action: () => {} }, 245 - { label: 'Delete Column', action: () => {} }, 246 - SEPARATOR, 247 - { label: 'Cell Format', icon: '⚙', action: () => {} }, 248 - { label: 'Add Note', icon: '📝', action: () => {} }, 249 - ]; 250 - } 251 - 252 - /** 253 - * Build menu items for right-clicking on a column header in Sheets. 254 - */ 255 - export function buildSheetsColumnHeaderItems() { 256 - return [ 257 - { label: 'Sort A \u2192 Z', icon: '↑', action: () => {} }, 258 - { label: 'Sort Z \u2192 A', icon: '↓', action: () => {} }, 259 - SEPARATOR, 260 - { label: 'Insert Column', action: () => {} }, 261 - { label: 'Delete Column', action: () => {} }, 262 - SEPARATOR, 263 - { label: 'Resize Column', action: () => {} }, 264 - ]; 265 - } 266 - 267 - /** 268 - * Build menu items for right-clicking on a row header in Sheets. 269 - */ 270 - export function buildSheetsRowHeaderItems() { 271 - return [ 272 - { label: 'Insert Row', action: () => {} }, 273 - { label: 'Delete Row', action: () => {} }, 274 - SEPARATOR, 275 - { label: 'Resize Row', action: () => {} }, 276 - ]; 277 - } 278 - 279 - /** 280 - * Dispatcher: return the appropriate menu items for a given sheets target. 281 - * 282 - * @param {'cell' | 'colHeader' | 'rowHeader'} target 283 - * @returns {Array} 284 - */ 285 - export function buildSheetsContextItems(target) { 286 - switch (target) { 287 - case 'cell': return buildSheetsCellItems(); 288 - case 'colHeader': return buildSheetsColumnHeaderItems(); 289 - case 'rowHeader': return buildSheetsRowHeaderItems(); 290 - default: return []; 291 - } 292 - }
+310
src/lib/context-menu.ts
··· 1 + /** 2 + * Context Menu — shared component for Docs and Sheets. 3 + * 4 + * Renders a custom right-click context menu with: 5 + * - Icon, label, shortcut hint per item 6 + * - Separator support 7 + * - Disabled state 8 + * - Keyboard navigation (arrow keys, Enter, Escape) 9 + * - Auto-positioning near screen edges 10 + */ 11 + 12 + export interface MenuItemConfig { 13 + label: string; 14 + action: () => void; 15 + icon?: string; 16 + shortcut?: string; 17 + disabled?: boolean; 18 + } 19 + 20 + export interface SeparatorItem { 21 + readonly type: 'separator'; 22 + } 23 + 24 + export type MenuItem = MenuItemConfig | SeparatorItem; 25 + 26 + export interface ContextMenuInstance { 27 + el: HTMLElement; 28 + show: (x: number, y: number) => void; 29 + hide: () => void; 30 + destroy: () => void; 31 + } 32 + 33 + export interface MenuPosition { 34 + x: number; 35 + y: number; 36 + } 37 + 38 + type SheetsContextTarget = 'cell' | 'colHeader' | 'rowHeader'; 39 + 40 + /** Sentinel value used as a separator between item groups. */ 41 + export const SEPARATOR: SeparatorItem = Object.freeze({ type: 'separator' as const }); 42 + 43 + /** 44 + * Position a context menu so it stays within the viewport. 45 + */ 46 + export function positionMenu(mouseX: number, mouseY: number, menuW: number, menuH: number, vpW: number, vpH: number): MenuPosition { 47 + let x = mouseX; 48 + let y = mouseY; 49 + 50 + if (x + menuW > vpW) x = vpW - menuW; 51 + if (y + menuH > vpH) y = vpH - menuH; 52 + if (x < 0) x = 0; 53 + if (y < 0) y = 0; 54 + 55 + return { x, y }; 56 + } 57 + 58 + function isSeparator(item: MenuItem): item is SeparatorItem { 59 + return item === SEPARATOR || ('type' in item && item.type === 'separator'); 60 + } 61 + 62 + /** 63 + * Create a context menu DOM element from a list of menu items. 64 + * 65 + * Each item is either SEPARATOR or an object: 66 + * { label: string, action: () => void, icon?: string, shortcut?: string, disabled?: boolean } 67 + */ 68 + export function createContextMenu(items: MenuItem[]): ContextMenuInstance { 69 + const menu = document.createElement('div'); 70 + menu.className = 'context-menu'; 71 + menu.setAttribute('role', 'menu'); 72 + menu.style.display = 'none'; 73 + menu.style.position = 'fixed'; 74 + menu.style.zIndex = '9999'; 75 + 76 + for (const item of items) { 77 + if (isSeparator(item)) { 78 + const sep = document.createElement('div'); 79 + sep.className = 'context-menu-separator'; 80 + sep.setAttribute('role', 'separator'); 81 + menu.appendChild(sep); 82 + continue; 83 + } 84 + 85 + const btn = document.createElement('button'); 86 + btn.className = 'context-menu-item'; 87 + btn.setAttribute('role', 'menuitem'); 88 + btn.setAttribute('tabindex', '0'); 89 + 90 + if (item.disabled) { 91 + btn.classList.add('disabled'); 92 + btn.setAttribute('aria-disabled', 'true'); 93 + } 94 + 95 + // Icon 96 + if (item.icon) { 97 + const iconSpan = document.createElement('span'); 98 + iconSpan.className = 'context-menu-icon'; 99 + iconSpan.textContent = item.icon; 100 + btn.appendChild(iconSpan); 101 + } 102 + 103 + // Label 104 + const labelSpan = document.createElement('span'); 105 + labelSpan.className = 'context-menu-label'; 106 + labelSpan.textContent = item.label; 107 + btn.appendChild(labelSpan); 108 + 109 + // Shortcut hint 110 + if (item.shortcut) { 111 + const shortcutSpan = document.createElement('span'); 112 + shortcutSpan.className = 'context-menu-shortcut'; 113 + shortcutSpan.textContent = item.shortcut; 114 + btn.appendChild(shortcutSpan); 115 + } 116 + 117 + // Click handler 118 + btn.addEventListener('click', (e: Event) => { 119 + e.stopPropagation(); 120 + if (item.disabled) return; 121 + item.action(); 122 + hide(); 123 + }); 124 + 125 + // Keyboard: Enter to activate 126 + btn.addEventListener('keydown', (e: KeyboardEvent) => { 127 + if (e.key === 'Enter') { 128 + e.preventDefault(); 129 + if (!item.disabled) { 130 + item.action(); 131 + hide(); 132 + } 133 + } 134 + }); 135 + 136 + menu.appendChild(btn); 137 + } 138 + 139 + // Keyboard navigation on the menu container 140 + menu.addEventListener('keydown', (e: KeyboardEvent) => { 141 + const focusable = Array.from(menu.querySelectorAll<HTMLButtonElement>('.context-menu-item:not(.disabled)')); 142 + const current = document.activeElement; 143 + const idx = focusable.indexOf(current as HTMLButtonElement); 144 + 145 + if (e.key === 'ArrowDown') { 146 + e.preventDefault(); 147 + const next = idx < focusable.length - 1 ? idx + 1 : 0; 148 + focusable[next]?.focus(); 149 + } else if (e.key === 'ArrowUp') { 150 + e.preventDefault(); 151 + const prev = idx > 0 ? idx - 1 : focusable.length - 1; 152 + focusable[prev]?.focus(); 153 + } else if (e.key === 'Escape') { 154 + e.preventDefault(); 155 + hide(); 156 + } 157 + }); 158 + 159 + function show(x: number, y: number): void { 160 + menu.style.display = 'block'; 161 + menu.style.left = x + 'px'; 162 + menu.style.top = y + 'px'; 163 + 164 + // Focus first item for keyboard navigation 165 + const first = menu.querySelector<HTMLButtonElement>('.context-menu-item:not(.disabled)'); 166 + if (first) first.focus(); 167 + } 168 + 169 + function hide(): void { 170 + menu.style.display = 'none'; 171 + } 172 + 173 + function destroy(): void { 174 + if (menu.parentNode) { 175 + menu.parentNode.removeChild(menu); 176 + } 177 + } 178 + 179 + return { el: menu, show, hide, destroy }; 180 + } 181 + 182 + // ---- Docs context menu item builders ---- 183 + 184 + /** 185 + * Build menu items for right-clicking on text in Docs. 186 + * Actions are no-ops here; the consumer wires them to the editor. 187 + */ 188 + export function buildDocsTextItems(): MenuItem[] { 189 + return [ 190 + { label: 'Cut', icon: '\u2702', shortcut: '\u2318X', action: () => {} }, 191 + { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => {} }, 192 + { label: 'Paste', icon: '\uD83D\uDCCB', shortcut: '\u2318V', action: () => {} }, 193 + { label: 'Select All', shortcut: '\u2318A', action: () => {} }, 194 + SEPARATOR, 195 + { label: 'Bold', icon: 'B', shortcut: '\u2318B', action: () => {} }, 196 + { label: 'Italic', icon: 'I', shortcut: '\u2318I', action: () => {} }, 197 + { label: 'Underline', icon: 'U', shortcut: '\u2318U', action: () => {} }, 198 + SEPARATOR, 199 + { label: 'Link', icon: '\uD83D\uDD17', shortcut: '\u2318K', action: () => {} }, 200 + { label: 'Comment', icon: '\uD83D\uDCAC', action: () => {} }, 201 + ]; 202 + } 203 + 204 + /** 205 + * Build menu items for right-clicking on a link in Docs. 206 + */ 207 + export function buildDocsLinkItems(): MenuItem[] { 208 + return [ 209 + { label: 'Open Link', icon: '\u2197', action: () => {} }, 210 + { label: 'Edit Link', icon: '\u270F', action: () => {} }, 211 + { label: 'Remove Link', icon: '\u2715', action: () => {} }, 212 + SEPARATOR, 213 + { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => {} }, 214 + ]; 215 + } 216 + 217 + /** 218 + * Build menu items for right-clicking on an image in Docs. 219 + */ 220 + export function buildDocsImageItems(): MenuItem[] { 221 + return [ 222 + { label: 'Image Properties', icon: '\u2699', action: () => {} }, 223 + SEPARATOR, 224 + { label: 'Cut', icon: '\u2702', shortcut: '\u2318X', action: () => {} }, 225 + { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => {} }, 226 + ]; 227 + } 228 + 229 + /** 230 + * Build menu items for right-clicking on a table in Docs. 231 + */ 232 + export function buildDocsTableItems(): MenuItem[] { 233 + return [ 234 + { label: 'Insert Row Above', action: () => {} }, 235 + { label: 'Insert Row Below', action: () => {} }, 236 + { label: 'Insert Column Left', action: () => {} }, 237 + { label: 'Insert Column Right', action: () => {} }, 238 + SEPARATOR, 239 + { label: 'Delete Row', action: () => {} }, 240 + { label: 'Delete Column', action: () => {} }, 241 + SEPARATOR, 242 + { label: 'Cut', icon: '\u2702', shortcut: '\u2318X', action: () => {} }, 243 + { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => {} }, 244 + { label: 'Paste', icon: '\uD83D\uDCCB', shortcut: '\u2318V', action: () => {} }, 245 + ]; 246 + } 247 + 248 + // ---- Sheets context menu item builders ---- 249 + 250 + /** 251 + * Build menu items for right-clicking on a cell in Sheets. 252 + */ 253 + export function buildSheetsCellItems(): MenuItem[] { 254 + return [ 255 + { label: 'Cut', icon: '\u2702', shortcut: '\u2318X', action: () => {} }, 256 + { label: 'Copy', icon: '\u29C9', shortcut: '\u2318C', action: () => {} }, 257 + { label: 'Paste', icon: '\uD83D\uDCCB', shortcut: '\u2318V', action: () => {} }, 258 + { label: 'Paste Special', icon: '\uD83D\uDCCB', action: () => {} }, 259 + SEPARATOR, 260 + { label: 'Insert Row Above', action: () => {} }, 261 + { label: 'Insert Row Below', action: () => {} }, 262 + { label: 'Insert Column Left', action: () => {} }, 263 + { label: 'Insert Column Right', action: () => {} }, 264 + SEPARATOR, 265 + { label: 'Delete Row', action: () => {} }, 266 + { label: 'Delete Column', action: () => {} }, 267 + SEPARATOR, 268 + { label: 'Cell Format', icon: '\u2699', action: () => {} }, 269 + { label: 'Add Note', icon: '\uD83D\uDCDD', action: () => {} }, 270 + ]; 271 + } 272 + 273 + /** 274 + * Build menu items for right-clicking on a column header in Sheets. 275 + */ 276 + export function buildSheetsColumnHeaderItems(): MenuItem[] { 277 + return [ 278 + { label: 'Sort A \u2192 Z', icon: '\u2191', action: () => {} }, 279 + { label: 'Sort Z \u2192 A', icon: '\u2193', action: () => {} }, 280 + SEPARATOR, 281 + { label: 'Insert Column', action: () => {} }, 282 + { label: 'Delete Column', action: () => {} }, 283 + SEPARATOR, 284 + { label: 'Resize Column', action: () => {} }, 285 + ]; 286 + } 287 + 288 + /** 289 + * Build menu items for right-clicking on a row header in Sheets. 290 + */ 291 + export function buildSheetsRowHeaderItems(): MenuItem[] { 292 + return [ 293 + { label: 'Insert Row', action: () => {} }, 294 + { label: 'Delete Row', action: () => {} }, 295 + SEPARATOR, 296 + { label: 'Resize Row', action: () => {} }, 297 + ]; 298 + } 299 + 300 + /** 301 + * Dispatcher: return the appropriate menu items for a given sheets target. 302 + */ 303 + export function buildSheetsContextItems(target: SheetsContextTarget | string): MenuItem[] { 304 + switch (target) { 305 + case 'cell': return buildSheetsCellItems(); 306 + case 'colHeader': return buildSheetsColumnHeaderItems(); 307 + case 'rowHeader': return buildSheetsRowHeaderItems(); 308 + default: return []; 309 + } 310 + }
+20 -15
src/lib/crypto.js src/lib/crypto.ts
··· 3 3 * Keys live only in the URL fragment — never sent to the server. 4 4 */ 5 5 6 - const ALGO = 'AES-GCM'; 7 - const KEY_LENGTH = 256; 8 - const IV_LENGTH = 12; // 96 bits for GCM 6 + const ALGO = 'AES-GCM' as const; 7 + const KEY_LENGTH = 256 as const; 8 + const IV_LENGTH = 12 as const; // 96 bits for GCM 9 9 10 10 /** Generate a fresh AES-256-GCM key. */ 11 - export async function generateKey() { 11 + export async function generateKey(): Promise<CryptoKey> { 12 12 return crypto.subtle.generateKey( 13 13 { name: ALGO, length: KEY_LENGTH }, 14 14 true, // extractable so we can put it in the URL ··· 17 17 } 18 18 19 19 /** Export a CryptoKey to a URL-safe base64 string. */ 20 - export async function exportKey(key) { 20 + export async function exportKey(key: CryptoKey): Promise<string> { 21 21 const raw = await crypto.subtle.exportKey('raw', key); 22 22 return bufToBase64url(new Uint8Array(raw)); 23 23 } 24 24 25 25 /** Import a URL-safe base64 string back to a CryptoKey. */ 26 - export async function importKey(b64) { 26 + export async function importKey(b64: string): Promise<CryptoKey> { 27 27 const raw = base64urlToBuf(b64); 28 28 return crypto.subtle.importKey( 29 29 'raw', raw, ··· 36 36 /** 37 37 * Encrypt plaintext bytes. Returns iv (12 bytes) || ciphertext. 38 38 */ 39 - export async function encrypt(plaintext, key) { 39 + export async function encrypt(plaintext: Uint8Array, key: CryptoKey): Promise<Uint8Array> { 40 40 const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); 41 41 const ciphertext = await crypto.subtle.encrypt( 42 42 { name: ALGO, iv }, ··· 52 52 /** 53 53 * Decrypt data produced by encrypt(). Expects iv || ciphertext. 54 54 */ 55 - export async function decrypt(data, key) { 55 + export async function decrypt(data: Uint8Array, key: CryptoKey): Promise<Uint8Array> { 56 56 const iv = data.slice(0, IV_LENGTH); 57 57 const ciphertext = data.slice(IV_LENGTH); 58 58 const plaintext = await crypto.subtle.decrypt( ··· 64 64 } 65 65 66 66 /** Encrypt a UTF-8 string. */ 67 - export async function encryptString(text, key) { 67 + export async function encryptString(text: string, key: CryptoKey): Promise<Uint8Array> { 68 68 return encrypt(new TextEncoder().encode(text), key); 69 69 } 70 70 71 71 /** Decrypt to a UTF-8 string. */ 72 - export async function decryptString(data, key) { 72 + export async function decryptString(data: Uint8Array, key: CryptoKey): Promise<string> { 73 73 const bytes = await decrypt(data, key); 74 74 return new TextDecoder().decode(bytes); 75 75 } 76 76 77 77 /** Generate a random document ID (UUID v4 without dashes). */ 78 - export function generateId() { 78 + export function generateId(): string { 79 79 return crypto.randomUUID().replace(/-/g, ''); 80 80 } 81 81 82 82 // --- URL-safe base64 helpers --- 83 83 84 - function bufToBase64url(buf) { 84 + function bufToBase64url(buf: Uint8Array): string { 85 85 let binary = ''; 86 86 for (const byte of buf) binary += String.fromCharCode(byte); 87 87 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 88 88 } 89 89 90 - function base64urlToBuf(str) { 90 + function base64urlToBuf(str: string): Uint8Array { 91 91 const b64 = str.replace(/-/g, '+').replace(/_/g, '/'); 92 92 const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); 93 93 const binary = atob(padded); ··· 96 96 return buf; 97 97 } 98 98 99 + export interface ParsedHash { 100 + docId: string; 101 + keyString: string; 102 + } 103 + 99 104 /** 100 105 * Parse the URL hash to extract docId and key. 101 106 * Format: #docId/base64urlKey 102 107 */ 103 - export function parseHash() { 108 + export function parseHash(): ParsedHash | null { 104 109 const hash = location.hash.slice(1); // remove '#' 105 110 if (!hash) return null; 106 111 const slash = hash.indexOf('/'); ··· 114 119 /** 115 120 * Build a hash string for a doc ID and key. 116 121 */ 117 - export async function buildHash(docId, key) { 122 + export async function buildHash(docId: string, key: CryptoKey): Promise<string> { 118 123 const keyStr = await exportKey(key); 119 124 return `#${docId}/${keyStr}`; 120 125 }
+46 -52
src/lib/offline.js src/lib/offline.ts
··· 6 6 * integrated in the UI layer; this module handles the pure logic. 7 7 */ 8 8 9 + type StatusChangeCallback = (online: boolean) => void; 10 + 9 11 /** 10 12 * Manages online/offline state and notifications. 11 13 */ 12 14 export class OfflineManager { 15 + private _online: boolean; 16 + private _reconnecting: boolean; 17 + private _listeners: StatusChangeCallback[]; 18 + 13 19 constructor() { 14 20 this._online = true; 15 21 this._reconnecting = false; 16 - /** @type {Array<function(boolean): void>} */ 17 22 this._listeners = []; 18 23 } 19 24 20 - /** 21 - * @returns {boolean} 22 - */ 23 - isOnline() { 25 + isOnline(): boolean { 24 26 return this._online; 25 27 } 26 28 27 29 /** 28 30 * Set online status. Notifies listeners only on change. 29 - * @param {boolean} online 30 31 */ 31 - setOnline(online) { 32 + setOnline(online: boolean): void { 32 33 if (this._online === online) return; 33 34 this._online = online; 34 35 for (const fn of this._listeners) fn(online); 35 36 } 36 37 37 - /** 38 - * @returns {boolean} 39 - */ 40 - isReconnecting() { 38 + isReconnecting(): boolean { 41 39 return this._reconnecting; 42 40 } 43 41 44 42 /** 45 43 * Set reconnecting state (syncing queued changes after coming back online). 46 - * @param {boolean} reconnecting 47 44 */ 48 - setReconnecting(reconnecting) { 45 + setReconnecting(reconnecting: boolean): void { 49 46 this._reconnecting = reconnecting; 50 47 } 51 48 52 49 /** 53 50 * Get human-readable status text. 54 - * @returns {string} 55 51 */ 56 - getStatusText() { 52 + getStatusText(): string { 57 53 if (!this._online) return 'Offline'; 58 54 if (this._reconnecting) return 'Syncing...'; 59 55 return 'Online'; ··· 61 57 62 58 /** 63 59 * Register a status change listener. 64 - * @param {function(boolean): void} fn 65 60 */ 66 - onStatusChange(fn) { 61 + onStatusChange(fn: StatusChangeCallback): void { 67 62 this._listeners.push(fn); 68 63 } 64 + } 65 + 66 + export interface QueueItem { 67 + type: string; 68 + data: Uint8Array; 69 + docId?: string; 70 + queuedAt?: number; 71 + [key: string]: unknown; 69 72 } 70 73 71 74 /** ··· 74 77 * Here we implement the pure queue logic. 75 78 */ 76 79 export class OfflineQueue { 80 + private _items: QueueItem[]; 81 + 77 82 constructor() { 78 - /** @type {Array<object>} */ 79 83 this._items = []; 80 84 } 81 85 82 - /** 83 - * @returns {number} 84 - */ 85 - size() { 86 + size(): number { 86 87 return this._items.length; 87 88 } 88 89 89 - /** 90 - * @returns {boolean} 91 - */ 92 - isEmpty() { 90 + isEmpty(): boolean { 93 91 return this._items.length === 0; 94 92 } 95 93 96 94 /** 97 95 * Add an item to the queue. 98 - * @param {object} item - { type, data, docId?, ... } 99 96 */ 100 - enqueue(item) { 97 + enqueue(item: Omit<QueueItem, 'queuedAt'> & { queuedAt?: number }): void { 101 98 this._items.push({ 102 99 ...item, 103 100 queuedAt: Date.now(), ··· 106 103 107 104 /** 108 105 * Remove and return the front item. 109 - * @returns {object|null} 110 106 */ 111 - dequeue() { 107 + dequeue(): QueueItem | null { 112 108 if (this._items.length === 0) return null; 113 - return this._items.shift(); 109 + return this._items.shift()!; 114 110 } 115 111 116 112 /** 117 113 * Peek at the front item without removing. 118 - * @returns {object|null} 119 114 */ 120 - peek() { 121 - return this._items.length > 0 ? this._items[0] : null; 115 + peek(): QueueItem | null { 116 + return this._items.length > 0 ? this._items[0]! : null; 122 117 } 123 118 124 119 /** 125 120 * Remove all items and return them in order. 126 - * @returns {Array<object>} 127 121 */ 128 - drain() { 122 + drain(): QueueItem[] { 129 123 const items = [...this._items]; 130 124 this._items = []; 131 125 return items; ··· 134 128 /** 135 129 * Remove all items. 136 130 */ 137 - clear() { 131 + clear(): void { 138 132 this._items = []; 139 133 } 140 134 } 141 135 136 + type CacheStrategyResult = 'cache-first' | 'network-first' | 'network-only'; 137 + 142 138 /** 143 139 * Cache strategy logic for the service worker. 144 140 * Determines which URLs to cache and which strategy to use. 145 141 */ 146 142 export class CacheStrategy { 143 + private _staticExtensions: readonly string[]; 144 + private _htmlExtensions: readonly string[]; 145 + private _networkOnlyPrefixes: readonly string[]; 146 + 147 147 constructor() { 148 - this._staticExtensions = ['.js', '.css', '.woff', '.woff2', '.ttf', '.png', '.jpg', '.svg', '.ico']; 149 - this._htmlExtensions = ['.html', '.htm']; 150 - this._networkOnlyPrefixes = ['/api/', '/ws', '/health']; 148 + this._staticExtensions = ['.js', '.css', '.woff', '.woff2', '.ttf', '.png', '.jpg', '.svg', '.ico'] as const; 149 + this._htmlExtensions = ['.html', '.htm'] as const; 150 + this._networkOnlyPrefixes = ['/api/', '/ws', '/health'] as const; 151 151 } 152 152 153 153 /** 154 154 * Should this URL be cached at all? 155 - * @param {string} url - URL path 156 - * @returns {boolean} 157 155 */ 158 - shouldCache(url) { 156 + shouldCache(url: string): boolean { 159 157 // Never cache API, WebSocket, or health endpoints 160 158 for (const prefix of this._networkOnlyPrefixes) { 161 159 if (url.startsWith(prefix) || url === prefix) return false; ··· 167 165 168 166 /** 169 167 * Get caching strategy for a URL. 170 - * @param {string} url 171 - * @returns {'cache-first'|'network-first'|'network-only'} 172 168 */ 173 - getStrategy(url) { 169 + getStrategy(url: string): CacheStrategyResult { 174 170 for (const prefix of this._networkOnlyPrefixes) { 175 171 if (url.startsWith(prefix) || url === prefix) return 'network-only'; 176 172 } ··· 182 178 183 179 /** 184 180 * Get the cache key for a URL (strip query params for static assets). 185 - * @param {string} url 186 - * @returns {string} 187 181 */ 188 - getCacheKey(url) { 182 + getCacheKey(url: string): string { 189 183 return this._stripQuery(url); 190 184 } 191 185 192 - _stripQuery(url) { 186 + private _stripQuery(url: string): string { 193 187 const idx = url.indexOf('?'); 194 188 return idx >= 0 ? url.slice(0, idx) : url; 195 189 } 196 190 197 - _isStatic(path) { 191 + private _isStatic(path: string): boolean { 198 192 return this._staticExtensions.some(ext => path.endsWith(ext)); 199 193 } 200 194 201 - _isHTML(path) { 195 + private _isHTML(path: string): boolean { 202 196 return this._htmlExtensions.some(ext => path.endsWith(ext)); 203 197 } 204 198 }
+48 -49
src/lib/print-layout.js src/lib/print-layout.ts
··· 8 8 * - Sheets-specific: grid lines, header repeat, scaling 9 9 */ 10 10 11 + export interface PageDimensions { 12 + width: string; 13 + height: string; 14 + } 15 + 16 + export interface MarginPreset { 17 + top: string; 18 + right: string; 19 + bottom: string; 20 + left: string; 21 + } 22 + 23 + type PageSizeKey = 'letter' | 'a4'; 24 + type MarginPresetKey = 'normal' | 'narrow' | 'wide'; 25 + type ScalingMode = 'fit-to-width' | 'fit-to-page' | 'actual-size'; 26 + 11 27 /** Standard page sizes. */ 12 - export const PAGE_SIZES = Object.freeze({ 28 + export const PAGE_SIZES: Readonly<Record<PageSizeKey, PageDimensions>> = Object.freeze({ 13 29 letter: { width: '8.5in', height: '11in' }, 14 30 a4: { width: '210mm', height: '297mm' }, 15 31 }); 16 32 17 33 /** Margin presets (all in inches for consistency). */ 18 - export const MARGIN_PRESETS = Object.freeze({ 34 + export const MARGIN_PRESETS: Readonly<Record<MarginPresetKey, MarginPreset>> = Object.freeze({ 19 35 normal: { top: '1in', right: '1in', bottom: '1in', left: '1in' }, 20 36 narrow: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }, 21 37 wide: { top: '1.5in', right: '1.5in', bottom: '1.5in', left: '1.5in' }, 22 38 }); 23 39 24 40 /** Available scaling modes for sheets printing. */ 25 - export const scalingModes = Object.freeze(['fit-to-width', 'fit-to-page', 'actual-size']); 41 + export const scalingModes: readonly ScalingMode[] = Object.freeze(['fit-to-width', 'fit-to-page', 'actual-size']); 26 42 27 43 /** 28 44 * Format a date for print headers. 29 - * @param {Date} [date] - Date to format; defaults to now. 30 - * @returns {string} 31 45 */ 32 - export function formatPrintDate(date) { 46 + export function formatPrintDate(date?: Date): string { 33 47 const d = date || new Date(); 34 48 return d.toLocaleDateString('en-US', { 35 49 year: 'numeric', ··· 40 54 41 55 /** 42 56 * Get page dimensions for a given size key. 43 - * @param {string} size - 'letter' or 'a4' 44 - * @returns {{ width: string, height: string }} 45 57 */ 46 - export function getPageDimensions(size) { 47 - return PAGE_SIZES[size] || PAGE_SIZES.letter; 58 + export function getPageDimensions(size: string): PageDimensions { 59 + return (PAGE_SIZES as Record<string, PageDimensions | undefined>)[size] || PAGE_SIZES.letter; 48 60 } 49 61 50 62 /** 51 63 * Build a print header HTML string. 52 64 * Layout: title on the left, date on the right. 53 - * 54 - * @param {string} title 55 - * @param {Date} [date] 56 - * @returns {string} 57 65 */ 58 - export function buildPrintHeader(title, date) { 66 + export function buildPrintHeader(title: string, date?: Date): string { 59 67 const dateStr = formatPrintDate(date); 60 68 return `<div class="print-header" style="display:flex;justify-content:space-between;align-items:center;padding:0 0 0.25in 0;border-bottom:1px solid #ccc;margin-bottom:0.25in;font-family:system-ui,-apple-system,sans-serif;font-size:10pt;color:#666;"> 61 69 <span class="print-header-title">${escapeHtml(title)}</span> ··· 66 74 /** 67 75 * Build a print footer HTML string. 68 76 * Layout: centered "Page X of Y". 69 - * 70 - * @param {number} page 71 - * @param {number} totalPages 72 - * @returns {string} 73 77 */ 74 - export function buildPrintFooter(page, totalPages) { 78 + export function buildPrintFooter(page: number, totalPages: number): string { 75 79 return `<div class="print-footer" style="text-align:center;padding:0.25in 0 0 0;border-top:1px solid #ccc;margin-top:0.25in;font-family:system-ui,-apple-system,sans-serif;font-size:9pt;color:#999;"> 76 80 Page ${page} of ${totalPages} 77 81 </div>`; ··· 79 83 80 84 /** 81 85 * Split HTML content at page-break markers. 82 - * 83 - * @param {string} html 84 - * @returns {string[]} Array of page content strings. 85 86 */ 86 - export function paginateContent(html) { 87 + export function paginateContent(html: string): string[] { 87 88 if (!html) return ['']; 88 89 89 90 // Split on page-break divs ··· 98 99 return parts.map(p => p.trim()); 99 100 } 100 101 102 + export interface DocsPrintOptions { 103 + title?: string; 104 + pageSize?: string; 105 + margins?: string; 106 + } 107 + 101 108 /** 102 109 * Build a full HTML document for printing Docs content. 103 - * 104 - * @param {string} editorHtml - The editor's inner HTML 105 - * @param {object} opts 106 - * @param {string} opts.title - Document title 107 - * @param {string} [opts.pageSize='letter'] - Page size key 108 - * @param {string} [opts.margins='normal'] - Margin preset key 109 - * @returns {string} Full HTML document 110 110 */ 111 - export function buildDocsPrintHtml(editorHtml, opts = {}) { 111 + export function buildDocsPrintHtml(editorHtml: string, opts: DocsPrintOptions = {}): string { 112 112 const title = opts.title || 'Untitled Document'; 113 113 const pageSize = opts.pageSize || 'letter'; 114 114 const margins = opts.margins || 'normal'; 115 115 116 116 const dims = getPageDimensions(pageSize); 117 - const margin = MARGIN_PRESETS[margins] || MARGIN_PRESETS.normal; 117 + const margin = (MARGIN_PRESETS as Record<string, MarginPreset | undefined>)[margins] || MARGIN_PRESETS.normal; 118 118 119 119 const pages = paginateContent(editorHtml); 120 120 const totalPages = pages.length; ··· 207 207 </html>`; 208 208 } 209 209 210 + export interface SheetsPrintData { 211 + headers: string[]; 212 + rows: string[][]; 213 + } 214 + 215 + export interface SheetsPrintOptions { 216 + pageSize?: string; 217 + gridLines?: boolean; 218 + repeatHeaders?: boolean; 219 + scaling?: ScalingMode; 220 + title?: string; 221 + } 222 + 210 223 /** 211 224 * Build a full HTML document for printing Sheets content. 212 - * 213 - * @param {object} data 214 - * @param {string[]} data.headers - Column headers 215 - * @param {string[][]} data.rows - Row data arrays 216 - * @param {object} opts 217 - * @param {string} [opts.pageSize='letter'] 218 - * @param {boolean} [opts.gridLines=true] 219 - * @param {boolean} [opts.repeatHeaders=false] 220 - * @param {string} [opts.scaling='actual-size'] - 'fit-to-width' | 'fit-to-page' | 'actual-size' 221 - * @param {string} [opts.title='Spreadsheet'] 222 - * @returns {string} Full HTML document 223 225 */ 224 - export function buildSheetsPrintHtml(data, opts = {}) { 226 + export function buildSheetsPrintHtml(data: SheetsPrintData, opts: SheetsPrintOptions = {}): string { 225 227 const pageSize = opts.pageSize || 'letter'; 226 228 const gridLines = opts.gridLines !== false; // default true 227 229 const repeatHeaders = opts.repeatHeaders || false; ··· 241 243 : ''; 242 244 243 245 // Build header row 244 - const headerTag = repeatHeaders ? 'thead' : 'tbody'; 245 246 const headerCells = data.headers.map(h => 246 247 `<th style="${borderStyle} padding: 4px 8px; background: #f5f5f5; font-weight: 600; font-size: 0.85rem; text-align: left;">${escapeHtml(String(h))}</th>` 247 248 ).join(''); ··· 320 321 321 322 /** 322 323 * Escape HTML special characters. 323 - * @param {string} str 324 - * @returns {string} 325 324 */ 326 - function escapeHtml(str) { 325 + function escapeHtml(str: string): string { 327 326 if (!str) return ''; 328 327 return str 329 328 .replace(/&/g, '&amp;')
+90 -52
src/lib/provider.js src/lib/provider.ts
··· 16 16 import { encrypt, decrypt } from './crypto.js'; 17 17 18 18 // Message types (first byte after decryption) 19 - const MSG_SYNC_STEP1 = 0; 20 - const MSG_SYNC_STEP2 = 1; 21 - const MSG_UPDATE = 2; 22 - const MSG_AWARENESS = 3; 19 + const MSG_SYNC_STEP1 = 0 as const; 20 + const MSG_SYNC_STEP2 = 1 as const; 21 + const MSG_UPDATE = 2 as const; 22 + const MSG_AWARENESS = 3 as const; 23 23 24 24 const SNAPSHOT_INTERVAL = 10_000; // Save snapshot every 10s 25 25 const SAVE_DEBOUNCE = 500; // Debounce save after changes 26 26 const MIN_SNAPSHOT_BYTES = 10; // Minimum plausible Yjs state size 27 27 28 + type ProviderEvent = 'sync' | 'status' | 'awareness'; 29 + 30 + interface StatusPayload { 31 + connected: boolean; 32 + } 33 + 34 + interface AwarenessUpdatePayload { 35 + added: number[]; 36 + updated: number[]; 37 + removed: number[]; 38 + } 39 + 40 + interface WsControlMessage { 41 + type: string; 42 + count?: number; 43 + } 44 + 45 + interface ProviderOptions { 46 + wsUrl?: string; 47 + apiUrl?: string; 48 + } 49 + 50 + type ProviderEventCallback = ((...args: [boolean] | [StatusPayload]) => void); 51 + 28 52 export class EncryptedProvider { 29 - /** 30 - * @param {Y.Doc} doc 31 - * @param {string} roomId 32 - * @param {CryptoKey} cryptoKey 33 - * @param {object} [opts] 34 - */ 35 - constructor(doc, roomId, cryptoKey, opts = {}) { 53 + doc: Y.Doc; 54 + roomId: string; 55 + cryptoKey: CryptoKey; 56 + wsUrl: string; 57 + apiUrl: string; 58 + ws: WebSocket | null; 59 + awareness: Awareness; 60 + connected: boolean; 61 + synced: boolean; 62 + _listeners: Record<ProviderEvent, ProviderEventCallback[]>; 63 + _snapshotTimer: ReturnType<typeof setInterval> | null; 64 + _saveDebounce: ReturnType<typeof setTimeout> | null; 65 + _destroyed: boolean; 66 + _hadSnapshot: boolean; 67 + _lastSaveTime: number | undefined; 68 + 69 + _onDocUpdate: (update: Uint8Array, origin: unknown) => void; 70 + _onAwarenessUpdate: (payload: AwarenessUpdatePayload) => void; 71 + _onBeforeUnload: () => void; 72 + 73 + constructor(doc: Y.Doc, roomId: string, cryptoKey: CryptoKey, opts: ProviderOptions = {}) { 36 74 this.doc = doc; 37 75 this.roomId = roomId; 38 76 this.cryptoKey = cryptoKey; ··· 49 87 this._hadSnapshot = false; // Track whether a snapshot was loaded 50 88 51 89 // Bind handlers 52 - this._onDocUpdate = this._onDocUpdate.bind(this); 53 - this._onAwarenessUpdate = this._onAwarenessUpdate.bind(this); 54 - this._onBeforeUnload = this._onBeforeUnload.bind(this); 90 + this._onDocUpdate = this._handleDocUpdate.bind(this); 91 + this._onAwarenessUpdate = this._handleAwarenessUpdate.bind(this); 92 + this._onBeforeUnload = this._handleBeforeUnload.bind(this); 55 93 56 94 doc.on('update', this._onDocUpdate); 57 95 this.awareness.on('update', this._onAwarenessUpdate); ··· 67 105 this.connect(); 68 106 } 69 107 70 - on(event, fn) { 108 + on(event: ProviderEvent, fn: ProviderEventCallback): void { 71 109 (this._listeners[event] || []).push(fn); 72 110 } 73 111 74 - _emit(event, ...args) { 75 - for (const fn of this._listeners[event] || []) fn(...args); 112 + _emit(event: ProviderEvent, ...args: [boolean] | [StatusPayload]): void { 113 + for (const fn of this._listeners[event] || []) fn(...args as [boolean & StatusPayload]); 76 114 } 77 115 78 - async connect() { 116 + async connect(): Promise<void> { 79 117 if (this._destroyed) return; 80 118 81 119 // Load persisted snapshot first ··· 85 123 this.ws = new WebSocket(url); 86 124 this.ws.binaryType = 'arraybuffer'; 87 125 88 - this.ws.onopen = () => { 126 + this.ws.onopen = (): void => { 89 127 this.connected = true; 90 128 this._emit('status', { connected: true }); 91 129 ··· 96 134 // to prevent saving empty/partial state before sync completes. 97 135 }; 98 136 99 - this.ws.onmessage = async (event) => { 137 + this.ws.onmessage = async (event: MessageEvent): Promise<void> => { 100 138 if (typeof event.data === 'string') { 101 139 // Control plane messages (unencrypted, no sensitive data) 102 140 try { 103 - const msg = JSON.parse(event.data); 141 + const msg = JSON.parse(event.data) as WsControlMessage; 104 142 if (msg.type === 'peer-count' && msg.count === 0 && !this.synced) { 105 143 // No peers — we're the only client. Mark as synced immediately 106 144 // so the editor becomes interactive (snapshot already loaded). ··· 110 148 // New peer needs our state — send sync step 1 to trigger exchange 111 149 this._sendSyncStep1(); 112 150 } 113 - } catch {} 151 + } catch { /* ignore parse errors */ } 114 152 return; 115 153 } 116 154 117 155 // Binary: encrypted Yjs message 118 156 try { 119 - const decrypted = await decrypt(new Uint8Array(event.data), this.cryptoKey); 157 + const decrypted = await decrypt(new Uint8Array(event.data as ArrayBuffer), this.cryptoKey); 120 158 this._handleDecryptedMessage(decrypted); 121 - } catch (err) { 159 + } catch (err: unknown) { 122 160 console.warn('Failed to decrypt message (wrong key?)', err); 123 161 } 124 162 }; 125 163 126 - this.ws.onclose = () => { 164 + this.ws.onclose = (): void => { 127 165 this.connected = false; 128 166 this.synced = false; 129 167 this._emit('status', { connected: false }); 130 - clearInterval(this._snapshotTimer); 168 + clearInterval(this._snapshotTimer!); 131 169 132 170 // Reconnect after delay 133 171 if (!this._destroyed) { ··· 135 173 } 136 174 }; 137 175 138 - this.ws.onerror = () => { 176 + this.ws.onerror = (): void => { 139 177 this.ws?.close(); 140 178 }; 141 179 } 142 180 143 - _handleDecryptedMessage(data) { 181 + _handleDecryptedMessage(data: Uint8Array): void { 144 182 const decoder = decoding.createDecoder(data); 145 183 const type = decoding.readVarUint(decoder); 146 184 ··· 149 187 // Peer sent their state vector — respond with missing updates 150 188 const sv = decoding.readVarUint8Array(decoder); 151 189 const update = Y.encodeStateAsUpdate(this.doc, sv); 152 - this._sendMessage(MSG_SYNC_STEP2, (enc) => { 190 + this._sendMessage(MSG_SYNC_STEP2, (enc: encoding.Encoder) => { 153 191 encoding.writeVarUint8Array(enc, update); 154 192 }); 155 193 break; ··· 172 210 } 173 211 174 212 /** Called when sync completes — starts periodic saves and emits event. */ 175 - _onSynced() { 213 + _onSynced(): void { 176 214 this.synced = true; 177 215 this._emit('sync', true); 178 216 // Start periodic snapshot saves now that we have a complete document state 179 - clearInterval(this._snapshotTimer); 217 + clearInterval(this._snapshotTimer!); 180 218 this._snapshotTimer = setInterval(() => this._saveSnapshot(), SNAPSHOT_INTERVAL); 181 219 } 182 220 183 - _sendSyncStep1() { 221 + _sendSyncStep1(): void { 184 222 const sv = Y.encodeStateVector(this.doc); 185 - this._sendMessage(MSG_SYNC_STEP1, (enc) => { 223 + this._sendMessage(MSG_SYNC_STEP1, (enc: encoding.Encoder) => { 186 224 encoding.writeVarUint8Array(enc, sv); 187 225 }); 188 226 } 189 227 190 - async _sendMessage(type, writeFn) { 228 + async _sendMessage(type: number, writeFn: (enc: encoding.Encoder) => void): Promise<void> { 191 229 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; 192 230 193 231 const encoder = encoding.createEncoder(); ··· 198 236 try { 199 237 const encrypted = await encrypt(plain, this.cryptoKey); 200 238 this.ws.send(encrypted); 201 - } catch (err) { 239 + } catch (err: unknown) { 202 240 console.error('Encryption failed', err); 203 241 } 204 242 } 205 243 206 - _onDocUpdate(update, origin) { 244 + _handleDocUpdate(update: Uint8Array, origin: unknown): void { 207 245 if (origin === this) return; // Don't echo back updates we received from peers 208 - this._sendMessage(MSG_UPDATE, (enc) => { 246 + this._sendMessage(MSG_UPDATE, (enc: encoding.Encoder) => { 209 247 encoding.writeVarUint8Array(enc, update); 210 248 }); 211 249 // Debounced save after every local change 212 250 this._debouncedSave(); 213 251 } 214 252 215 - _debouncedSave() { 253 + _debouncedSave(): void { 216 254 if (!this.synced) return; // Don't save before sync completes 217 - clearTimeout(this._saveDebounce); 255 + clearTimeout(this._saveDebounce!); 218 256 this._saveDebounce = setTimeout(() => this._saveSnapshot(), SAVE_DEBOUNCE); 219 257 } 220 258 221 - _onBeforeUnload() { 259 + _handleBeforeUnload(): void { 222 260 // Fire the save immediately (async — browser gives us a brief window) 223 261 this._saveSnapshot(); 224 262 } 225 263 226 - _onAwarenessUpdate({ added, updated, removed }) { 264 + _handleAwarenessUpdate({ added, updated, removed }: AwarenessUpdatePayload): void { 227 265 const changed = [...added, ...updated, ...removed]; 228 266 const update = encodeAwarenessUpdate(this.awareness, changed); 229 - this._sendMessage(MSG_AWARENESS, (enc) => { 267 + this._sendMessage(MSG_AWARENESS, (enc: encoding.Encoder) => { 230 268 encoding.writeVarUint8Array(enc, update); 231 269 }); 232 270 } 233 271 234 - async _loadSnapshot() { 272 + async _loadSnapshot(): Promise<void> { 235 273 try { 236 274 const res = await fetch(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`); 237 275 if (!res.ok) return; // No snapshot yet ··· 246 284 247 285 Y.applyUpdate(this.doc, plain); 248 286 this._hadSnapshot = true; 249 - } catch (err) { 287 + } catch (err: unknown) { 250 288 // No snapshot or wrong key — start fresh 251 289 console.log('No existing snapshot or decryption failed, starting fresh'); 252 290 } 253 291 } 254 292 255 - async _saveSnapshot() { 293 + async _saveSnapshot(): Promise<void> { 256 294 // Don't save before sync completes — we'd overwrite real data with empty state 257 295 if (!this.synced) return; 258 296 259 297 // Don't save while an import is in progress — the doc may be partially populated 260 - if (typeof window !== 'undefined' && window.__importInProgress) return; 298 + if (typeof window !== 'undefined' && (window as Window & { __importInProgress?: boolean }).__importInProgress) return; 261 299 262 300 try { 263 301 const state = Y.encodeStateAsUpdate(this.doc); ··· 275 313 body: encrypted, 276 314 }); 277 315 this._lastSaveTime = Date.now(); 278 - } catch (err) { 316 + } catch (err: unknown) { 279 317 console.warn('Failed to save snapshot', err); 280 318 } 281 319 } 282 320 283 321 /** Set local awareness state (user info, cursor position, etc.) */ 284 - setAwareness(state) { 322 + setAwareness(state: Record<string, unknown>): void { 285 323 this.awareness.setLocalStateField('user', state); 286 324 } 287 325 288 - disconnect() { 326 + disconnect(): void { 289 327 if (this.ws) { 290 328 this.ws.close(); 291 329 this.ws = null; 292 330 } 293 - clearInterval(this._snapshotTimer); 331 + clearInterval(this._snapshotTimer!); 294 332 this.connected = false; 295 333 } 296 334 297 - async destroy() { 335 + async destroy(): Promise<void> { 298 336 this._destroyed = true; 299 - clearTimeout(this._saveDebounce); 337 + clearTimeout(this._saveDebounce!); 300 338 await this._saveSnapshot(); 301 339 this.disconnect(); 302 340 this.doc.off('update', this._onDocUpdate);
+54 -36
src/lib/share-dialog.js src/lib/share-dialog.ts
··· 8 8 * - Share dialog open/close/copy logic 9 9 */ 10 10 11 + type ShareMode = 'edit' | 'view'; 12 + 13 + type ExpiryOption = 'none' | '1h' | '1d' | '7d' | '30d'; 14 + 15 + interface ShareDialogConfig { 16 + docId: string; 17 + docType: string; 18 + keyString: string; 19 + } 20 + 21 + interface DocumentShareInfo { 22 + share_mode?: string; 23 + [key: string]: unknown; 24 + } 25 + 26 + const expiryLabels: Record<string, string> = { 27 + 'none': 'No expiry', 28 + '1h': '1 hour', 29 + '1d': '1 day', 30 + '7d': '7 days', 31 + '30d': '30 days', 32 + }; 33 + 34 + const expiryDurations: Record<string, number> = { 35 + '1h': 60 * 60 * 1000, 36 + '1d': 24 * 60 * 60 * 1000, 37 + '7d': 7 * 24 * 60 * 60 * 1000, 38 + '30d': 30 * 24 * 60 * 60 * 1000, 39 + }; 40 + 11 41 /** Build a share URL for a document. */ 12 - export function buildShareUrl(baseUrl, docType, docId, keyString, mode) { 42 + export function buildShareUrl(baseUrl: string, docType: string, docId: string, keyString: string, mode: ShareMode): string { 13 43 const url = `${baseUrl}/${docType}/${docId}#${keyString}`; 14 44 if (mode === 'view') { 15 45 return url + '?mode=view'; ··· 18 48 } 19 49 20 50 /** Check if current URL indicates view-only mode. */ 21 - export function isViewMode() { 51 + export function isViewMode(): boolean { 22 52 const params = new URLSearchParams(location.search); 23 53 return params.get('mode') === 'view'; 24 54 } 25 55 26 56 /** Parse view mode from a search params string value. */ 27 - export function parseViewMode(modeValue) { 57 + export function parseViewMode(modeValue: string | null): boolean { 28 58 return modeValue === 'view'; 29 59 } 30 60 31 61 /** Map expiry option to human-readable label. */ 32 - export function getExpiryLabel(expiryOption) { 33 - const labels = { 34 - 'none': 'No expiry', 35 - '1h': '1 hour', 36 - '1d': '1 day', 37 - '7d': '7 days', 38 - '30d': '30 days', 39 - }; 40 - return labels[expiryOption] || 'No expiry'; 62 + export function getExpiryLabel(expiryOption: string): string { 63 + return expiryLabels[expiryOption] || 'No expiry'; 41 64 } 42 65 43 66 /** Compute an ISO date string for the given expiry option. */ 44 - export function computeExpiryDate(option) { 67 + export function computeExpiryDate(option: string | null): string | null { 45 68 if (option === 'none' || !option) return null; 69 + const duration = expiryDurations[option]; 70 + if (!duration) return null; 46 71 const now = Date.now(); 47 - const durations = { 48 - '1h': 60 * 60 * 1000, 49 - '1d': 24 * 60 * 60 * 1000, 50 - '7d': 7 * 24 * 60 * 60 * 1000, 51 - '30d': 30 * 24 * 60 * 60 * 1000, 52 - }; 53 - if (!durations[option]) return null; 54 - return new Date(now + durations[option]).toISOString(); 72 + return new Date(now + duration).toISOString(); 55 73 } 56 74 57 75 /** Initialize share dialog event listeners. */ 58 - export function initShareDialog({ docId, docType, keyString }) { 76 + export function initShareDialog({ docId, docType, keyString }: ShareDialogConfig): void { 59 77 const shareBtn = document.getElementById('btn-share'); 60 78 const shareDialog = document.getElementById('share-dialog'); 61 79 const shareClose = document.getElementById('share-dialog-close'); 62 - const shareLinkInput = document.getElementById('share-link-input'); 80 + const shareLinkInput = document.getElementById('share-link-input') as HTMLInputElement | null; 63 81 const shareCopyLink = document.getElementById('share-copy-link'); 64 - const shareModeSelect = document.getElementById('share-mode-select'); 65 - const shareExpiry = document.getElementById('share-expiry'); 82 + const shareModeSelect = document.getElementById('share-mode-select') as HTMLSelectElement | null; 83 + const shareExpiry = document.getElementById('share-expiry') as HTMLSelectElement | null; 66 84 67 85 if (!shareBtn || !shareDialog) return; 68 86 69 - function updateShareLink() { 70 - const mode = shareModeSelect ? shareModeSelect.value : 'edit'; 87 + function updateShareLink(): void { 88 + const mode = (shareModeSelect ? shareModeSelect.value : 'edit') as ShareMode; 71 89 const url = buildShareUrl( 72 90 location.origin, 73 91 docType, ··· 84 102 85 103 // Load current share settings from server 86 104 fetch(`/api/documents/${docId}`) 87 - .then(r => r.json()) 105 + .then(r => r.json() as Promise<DocumentShareInfo>) 88 106 .then(doc => { 89 107 if (shareModeSelect && doc.share_mode) { 90 108 shareModeSelect.value = doc.share_mode; 91 109 updateShareLink(); 92 110 } 93 111 }) 94 - .catch(() => {}); 112 + .catch(() => { /* ignore */ }); 95 113 }); 96 114 97 115 if (shareClose) { ··· 101 119 } 102 120 103 121 // Click backdrop to close 104 - shareDialog.addEventListener('click', (e) => { 122 + shareDialog.addEventListener('click', (e: Event) => { 105 123 if (e.target === shareDialog) { 106 124 shareDialog.style.display = 'none'; 107 125 } 108 126 }); 109 127 110 128 // Escape to close 111 - shareDialog.addEventListener('keydown', (e) => { 129 + shareDialog.addEventListener('keydown', (e: KeyboardEvent) => { 112 130 if (e.key === 'Escape') { 113 131 shareDialog.style.display = 'none'; 114 132 } ··· 137 155 method: 'PUT', 138 156 headers: { 'Content-Type': 'application/json' }, 139 157 body: JSON.stringify({ share_mode: shareModeSelect.value }), 140 - }).catch(() => {}); 158 + }).catch(() => { /* ignore */ }); 141 159 }); 142 160 } 143 161 ··· 148 166 method: 'PUT', 149 167 headers: { 'Content-Type': 'application/json' }, 150 168 body: JSON.stringify({ expires_at: expiryDate }), 151 - }).catch(() => {}); 169 + }).catch(() => { /* ignore */ }); 152 170 }); 153 171 } 154 172 } 155 173 156 174 /** Apply view-only mode: disable editing, show badge. */ 157 - export function applyViewOnlyMode() { 175 + export function applyViewOnlyMode(): void { 158 176 const badge = document.getElementById('view-only-badge'); 159 177 if (badge) badge.style.display = ''; 160 178 161 179 const toolbar = document.getElementById('toolbar'); 162 180 if (toolbar) { 163 - toolbar.querySelectorAll('button, select, input').forEach(el => { 181 + toolbar.querySelectorAll<HTMLButtonElement | HTMLSelectElement | HTMLInputElement>('button, select, input').forEach(el => { 164 182 el.disabled = true; 165 183 }); 166 184 toolbar.style.opacity = '0.5'; ··· 168 186 } 169 187 170 188 // Disable doc title editing 171 - const titleInput = document.getElementById('doc-title'); 189 + const titleInput = document.getElementById('doc-title') as HTMLInputElement | null; 172 190 if (titleInput) titleInput.readOnly = true; 173 191 }
+78 -85
src/lib/suggesting.js src/lib/suggesting.ts
··· 12 12 export const SUGGESTION_TYPES = { 13 13 INSERT: 'suggestion-insert', 14 14 DELETE: 'suggestion-delete', 15 - }; 15 + } as const; 16 16 17 17 /** Default timeout (ms) after which a new suggestion session starts. */ 18 18 export const SESSION_TIMEOUT_MS = 2000; 19 + 20 + type SuggestionType = 'insert' | 'delete'; 21 + type EditorMode = 'editing' | 'suggesting'; 22 + 23 + export interface SuggestionAttrs { 24 + suggestionId: string; 25 + author: string; 26 + type: SuggestionType; 27 + timestamp: string; 28 + } 29 + 30 + export interface SuggestionAction { 31 + action: 'remove-mark' | 'delete-text'; 32 + type: SuggestionType; 33 + suggestionId: string; 34 + } 35 + 36 + interface CreateSuggestionOptions { 37 + type: SuggestionType; 38 + author: string; 39 + timestamp?: string; 40 + } 41 + 42 + interface SessionGetAttrsOptions { 43 + type: SuggestionType; 44 + author: string; 45 + cursorPos: number | null; 46 + timestamp?: string; 47 + } 48 + 49 + interface SuggestionSessionOptions { 50 + timeoutMs?: number; 51 + now?: () => number; 52 + } 53 + 54 + interface SuggestionManagerOptions { 55 + sessionTimeoutMs?: number; 56 + now?: () => number; 57 + } 19 58 20 59 /** 21 60 * Generate a fresh suggestion ID. 22 - * @returns {string} 23 61 */ 24 - function generateId() { 62 + function generateId(): string { 25 63 return typeof crypto !== 'undefined' && crypto.randomUUID 26 64 ? crypto.randomUUID() 27 65 : `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; ··· 29 67 30 68 /** 31 69 * Create suggestion mark attributes. 32 - * @param {object} opts 33 - * @param {'insert'|'delete'} opts.type 34 - * @param {string} opts.author 35 - * @param {string} [opts.timestamp] - ISO string, defaults to now 36 - * @returns {object} Mark attributes 37 70 */ 38 - export function createSuggestionAttrs(opts) { 71 + export function createSuggestionAttrs(opts: CreateSuggestionOptions): SuggestionAttrs { 39 72 return { 40 73 suggestionId: generateId(), 41 74 author: opts.author, ··· 57 90 * - `resetSession()` is called explicitly 58 91 */ 59 92 export class SuggestionSession { 60 - /** 61 - * @param {object} [opts] 62 - * @param {number} [opts.timeoutMs=SESSION_TIMEOUT_MS] - inactivity timeout 63 - * @param {() => number} [opts.now] - clock function (for testing) 64 - */ 65 - constructor(opts = {}) { 93 + private _timeoutMs: number; 94 + private _now: () => number; 95 + private _id: string | null; 96 + private _author: string | null; 97 + private _type: SuggestionType | null; 98 + private _cursorPos: number | null; 99 + private _lastTime: number; 100 + 101 + constructor(opts: SuggestionSessionOptions = {}) { 66 102 this._timeoutMs = opts.timeoutMs ?? SESSION_TIMEOUT_MS; 67 103 this._now = opts.now ?? (() => Date.now()); 68 104 69 - /** @type {string|null} Current session suggestion ID */ 70 105 this._id = null; 71 - /** @type {string|null} Author of the current session */ 72 106 this._author = null; 73 - /** @type {'insert'|'delete'|null} Type of the current session */ 74 107 this._type = null; 75 - /** @type {number|null} Last cursor position (end of last edit) */ 76 108 this._cursorPos = null; 77 - /** @type {number} Timestamp of last edit */ 78 109 this._lastTime = 0; 79 110 } 80 111 ··· 84 115 * If the incoming edit is compatible with the active session (same author, 85 116 * same type, adjacent cursor, within timeout), the existing session ID is 86 117 * reused. Otherwise a new session is started. 87 - * 88 - * @param {object} opts 89 - * @param {'insert'|'delete'} opts.type 90 - * @param {string} opts.author 91 - * @param {number} opts.cursorPos - cursor position *before* this edit 92 - * @param {string} [opts.timestamp] - ISO string, defaults to now 93 - * @returns {object} Mark attributes (always contains `suggestionId`) 94 118 */ 95 - getAttrs(opts) { 119 + getAttrs(opts: SessionGetAttrsOptions): SuggestionAttrs { 96 120 const now = this._now(); 97 121 const needsNew = this._needsNewSession(opts.author, opts.type, opts.cursorPos, now); 98 122 ··· 106 130 this._lastTime = now; 107 131 108 132 return { 109 - suggestionId: this._id, 133 + suggestionId: this._id!, 110 134 author: opts.author, 111 135 type: opts.type, 112 136 timestamp: opts.timestamp || new Date().toISOString(), ··· 116 140 /** 117 141 * Update the cursor position after an edit completes (e.g. set to end of 118 142 * inserted text so the next consecutive keystroke is recognised as adjacent). 119 - * @param {number} pos 120 143 */ 121 - updateCursor(pos) { 144 + updateCursor(pos: number): void { 122 145 this._cursorPos = pos; 123 146 } 124 147 ··· 126 149 * Forcefully end the current session. The next `getAttrs` call will start 127 150 * a fresh session. 128 151 */ 129 - resetSession() { 152 + resetSession(): void { 130 153 this._id = null; 131 154 this._author = null; 132 155 this._type = null; ··· 136 159 137 160 /** 138 161 * Return the current session ID (or null if no session is active). 139 - * @returns {string|null} 140 162 */ 141 - get currentId() { 163 + get currentId(): string | null { 142 164 return this._id; 143 165 } 144 166 ··· 146 168 147 169 /** 148 170 * Determine whether we need to start a new session. 149 - * @private 150 171 */ 151 - _needsNewSession(author, type, cursorPos, now) { 172 + private _needsNewSession(author: string, type: SuggestionType, cursorPos: number | null, now: number): boolean { 152 173 // No active session 153 174 if (!this._id) return true; 154 175 // Different author ··· 169 190 * This is the pure-logic layer; TipTap mark management is handled separately. 170 191 */ 171 192 export class SuggestionManager { 172 - /** 173 - * @param {object} [opts] 174 - * @param {number} [opts.sessionTimeoutMs] - inactivity timeout for session grouping 175 - * @param {() => number} [opts.now] - clock function (for testing) 176 - */ 177 - constructor(opts = {}) { 178 - /** @type {'editing'|'suggesting'} */ 193 + private _mode: EditorMode; 194 + private _suggestions: Map<string, SuggestionAttrs>; 195 + private _session: SuggestionSession; 196 + 197 + constructor(opts: SuggestionManagerOptions = {}) { 179 198 this._mode = 'editing'; 180 - 181 - /** @type {Map<string, object>} suggestionId -> attrs */ 182 199 this._suggestions = new Map(); 183 200 184 201 /** Session tracker for grouping consecutive edits */ ··· 190 207 191 208 /** 192 209 * Check if currently in suggesting mode. 193 - * @returns {boolean} 194 210 */ 195 - isSuggesting() { 211 + isSuggesting(): boolean { 196 212 return this._mode === 'suggesting'; 197 213 } 198 214 199 215 /** 200 216 * Set the editing mode. 201 - * @param {'editing'|'suggesting'} mode 202 217 */ 203 - setMode(mode) { 218 + setMode(mode: EditorMode): void { 204 219 this._mode = mode; 205 220 if (mode === 'editing') { 206 221 this._session.resetSession(); ··· 210 225 /** 211 226 * Toggle between editing and suggesting mode. 212 227 */ 213 - toggleMode() { 228 + toggleMode(): void { 214 229 this._mode = this._mode === 'editing' ? 'suggesting' : 'editing'; 215 230 if (this._mode === 'editing') { 216 231 this._session.resetSession(); ··· 220 235 /** 221 236 * Get session-aware suggestion attributes. Consecutive edits by the same 222 237 * author/type at adjacent positions share the same suggestion ID. 223 - * 224 - * @param {object} opts 225 - * @param {'insert'|'delete'} opts.type 226 - * @param {string} opts.author 227 - * @param {number} opts.cursorPos - cursor position before this edit 228 - * @param {string} [opts.timestamp] 229 - * @returns {object} Mark attributes 230 238 */ 231 - getSessionAttrs(opts) { 239 + getSessionAttrs(opts: SessionGetAttrsOptions): SuggestionAttrs { 232 240 return this._session.getAttrs(opts); 233 241 } 234 242 235 243 /** 236 244 * Update the session cursor position after an edit. 237 - * @param {number} pos 238 245 */ 239 - updateSessionCursor(pos) { 246 + updateSessionCursor(pos: number): void { 240 247 this._session.updateCursor(pos); 241 248 } 242 249 243 250 /** 244 251 * Reset the current suggestion session (e.g. on explicit cursor move). 245 252 */ 246 - resetSession() { 253 + resetSession(): void { 247 254 this._session.resetSession(); 248 255 } 249 256 250 257 /** 251 258 * Track a new suggestion. 252 - * @param {object} attrs - From createSuggestionAttrs or getSessionAttrs 253 259 */ 254 - addSuggestion(attrs) { 260 + addSuggestion(attrs: SuggestionAttrs): void { 255 261 this._suggestions.set(attrs.suggestionId, { ...attrs }); 256 262 } 257 263 258 264 /** 259 265 * Get all tracked suggestions. 260 - * @returns {Array<object>} 261 266 */ 262 - getSuggestions() { 267 + getSuggestions(): SuggestionAttrs[] { 263 268 return Array.from(this._suggestions.values()); 264 269 } 265 270 266 271 /** 267 272 * Get a suggestion by ID. 268 - * @param {string} id 269 - * @returns {object|null} 270 273 */ 271 - getSuggestion(id) { 274 + getSuggestion(id: string): SuggestionAttrs | null { 272 275 return this._suggestions.get(id) || null; 273 276 } 274 277 275 278 /** 276 279 * Get suggestions filtered by author. 277 - * @param {string} author 278 - * @returns {Array<object>} 279 280 */ 280 - getSuggestionsByAuthor(author) { 281 + getSuggestionsByAuthor(author: string): SuggestionAttrs[] { 281 282 return this.getSuggestions().filter(s => s.author === author); 282 283 } 283 284 ··· 286 287 * 287 288 * - Accept INSERT: keep the text, remove the suggestion mark -> { action: 'remove-mark', type: 'insert' } 288 289 * - Accept DELETE: actually delete the text -> { action: 'delete-text', type: 'delete' } 289 - * 290 - * @param {string} id 291 - * @returns {object|null} Action descriptor or null if not found 292 290 */ 293 - accept(id) { 291 + accept(id: string): SuggestionAction | null { 294 292 const suggestion = this._suggestions.get(id); 295 293 if (!suggestion) return null; 296 294 ··· 308 306 * 309 307 * - Reject INSERT: remove the inserted text -> { action: 'delete-text', type: 'insert' } 310 308 * - Reject DELETE: keep the text, remove the mark -> { action: 'remove-mark', type: 'delete' } 311 - * 312 - * @param {string} id 313 - * @returns {object|null} Action descriptor or null if not found 314 309 */ 315 - reject(id) { 310 + reject(id: string): SuggestionAction | null { 316 311 const suggestion = this._suggestions.get(id); 317 312 if (!suggestion) return null; 318 313 ··· 327 322 328 323 /** 329 324 * Accept all suggestions. Returns array of action descriptors. 330 - * @returns {Array<object>} 331 325 */ 332 - acceptAll() { 326 + acceptAll(): SuggestionAction[] { 333 327 const ids = Array.from(this._suggestions.keys()); 334 - return ids.map(id => this.accept(id)).filter(Boolean); 328 + return ids.map(id => this.accept(id)).filter((r): r is SuggestionAction => r !== null); 335 329 } 336 330 337 331 /** 338 332 * Reject all suggestions. Returns array of action descriptors. 339 - * @returns {Array<object>} 340 333 */ 341 - rejectAll() { 334 + rejectAll(): SuggestionAction[] { 342 335 const ids = Array.from(this._suggestions.keys()); 343 - return ids.map(id => this.reject(id)).filter(Boolean); 336 + return ids.map(id => this.reject(id)).filter((r): r is SuggestionAction => r !== null); 344 337 } 345 338 }
+47 -29
src/lib/version-history.js src/lib/version-history.ts
··· 8 8 * - Version retrieval and restore 9 9 */ 10 10 11 + export interface VersionMetadata { 12 + author: string | null; 13 + wordCount: number; 14 + } 15 + 16 + export interface VersionEntry { 17 + id: string; 18 + snapshot: Uint8Array; 19 + author: string | null; 20 + wordCount: number; 21 + timestamp: number; 22 + } 23 + 24 + export interface VersionInfo { 25 + id: string; 26 + author: string | null; 27 + wordCount: number; 28 + timestamp: number; 29 + } 30 + 31 + export interface VersionInfoWithDelta extends VersionInfo { 32 + wordCountDelta: string; 33 + } 34 + 35 + export interface VersionManagerOptions { 36 + maxVersions?: number; 37 + editThreshold?: number; 38 + timeThresholdMs?: number; 39 + } 40 + 11 41 /** 12 42 * Count words in a text string. 13 - * @param {string} text 14 - * @returns {number} 15 43 */ 16 - export function computeWordCount(text) { 44 + export function computeWordCount(text: string): number { 17 45 if (!text || !text.trim()) return 0; 18 46 return text.trim().split(/\s+/).length; 19 47 } 20 48 21 49 /** 22 50 * Compute a display string for word count change between versions. 23 - * @param {number|null} previousCount - word count of previous version (null if first) 24 - * @param {number} currentCount - word count of this version 25 - * @returns {string} e.g. "+5", "-3", "0" 26 51 */ 27 - export function computeWordCountDelta(previousCount, currentCount) { 52 + export function computeWordCountDelta(previousCount: number | null | undefined, currentCount: number): string { 28 53 if (previousCount === null || previousCount === undefined) { 29 54 return `+${currentCount}`; 30 55 } ··· 38 63 * Manages version history for a document. 39 64 */ 40 65 export class VersionManager { 41 - /** 42 - * @param {object} opts 43 - * @param {number} opts.maxVersions - Maximum versions to keep (FIFO) 44 - * @param {number} opts.editThreshold - Number of edits before auto-capture 45 - * @param {number} opts.timeThresholdMs - Time in ms before auto-capture 46 - */ 47 - constructor(opts = {}) { 66 + _maxVersions: number; 67 + _editThreshold: number; 68 + _timeThresholdMs: number; 69 + _versions: VersionEntry[]; 70 + _editCount: number; 71 + _lastCaptureTime: number; 72 + 73 + constructor(opts: VersionManagerOptions = {}) { 48 74 this._maxVersions = opts.maxVersions || 50; 49 75 this._editThreshold = opts.editThreshold || 50; 50 76 this._timeThresholdMs = opts.timeThresholdMs || 5 * 60 * 1000; 51 77 52 - /** @type {Array<{id: string, snapshot: Uint8Array, author: string, wordCount: number, timestamp: number}>} */ 53 78 this._versions = []; 54 - 55 79 this._editCount = 0; 56 80 this._lastCaptureTime = Date.now(); 57 81 } ··· 59 83 /** 60 84 * Record a single edit. Used by the editor to track edits toward threshold. 61 85 */ 62 - recordEdit() { 86 + recordEdit(): void { 63 87 this._editCount++; 64 88 } 65 89 ··· 68 92 * Returns true if edit threshold OR time threshold is reached. 69 93 * At least one edit must have occurred for time trigger. 70 94 */ 71 - shouldCapture() { 95 + shouldCapture(): boolean { 72 96 if (this._editCount >= this._editThreshold) return true; 73 97 if (this._editCount > 0 && (Date.now() - this._lastCaptureTime) >= this._timeThresholdMs) return true; 74 98 return false; ··· 77 101 /** 78 102 * Reset capture counters. Call after a version is captured. 79 103 */ 80 - resetCapture() { 104 + resetCapture(): void { 81 105 this._editCount = 0; 82 106 this._lastCaptureTime = Date.now(); 83 107 } 84 108 85 109 /** 86 110 * Add a version snapshot. 87 - * @param {Uint8Array} snapshot - The document state 88 - * @param {object} metadata - { author, wordCount } 89 111 */ 90 - addVersion(snapshot, metadata) { 112 + addVersion(snapshot: Uint8Array, metadata: { author?: string | null; wordCount?: number }): void { 91 113 const id = typeof crypto !== 'undefined' && crypto.randomUUID 92 114 ? crypto.randomUUID() 93 115 : `v-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; ··· 112 134 * Get all versions (newest first) with computed word count deltas. 113 135 * Snapshots are NOT included in the returned objects (use getSnapshot). 114 136 */ 115 - getVersions() { 137 + getVersions(): VersionInfoWithDelta[] { 116 138 // Create a reversed copy (newest first) 117 139 const reversed = [...this._versions].reverse(); 118 140 ··· 133 155 134 156 /** 135 157 * Get a specific version by ID (without snapshot). 136 - * @param {string} id 137 - * @returns {object|null} 138 158 */ 139 - getVersion(id) { 159 + getVersion(id: string): VersionInfo | null { 140 160 const v = this._versions.find(v => v.id === id); 141 161 if (!v) return null; 142 162 return { ··· 149 169 150 170 /** 151 171 * Get the snapshot data for a specific version. 152 - * @param {string} id 153 - * @returns {Uint8Array|null} 154 172 */ 155 - getSnapshot(id) { 173 + getSnapshot(id: string): Uint8Array | null { 156 174 const v = this._versions.find(v => v.id === id); 157 175 return v ? v.snapshot : null; 158 176 }
-93
src/sheets/cell-notes.js
··· 1 - /** 2 - * Cell Notes — plain text annotations on individual cells. 3 - * 4 - * Notes are displayed as hover tooltips with a small triangle 5 - * indicator in the top-right corner of the cell. In the real app, 6 - * the notes object is backed by a Yjs Map for collaboration sync. 7 - * 8 - * These functions operate on a plain object { cellId: noteText } 9 - * and return a new object (immutable pattern for testability). 10 - */ 11 - 12 - /** 13 - * Create or set a note on a cell. Trims whitespace. 14 - * If text is empty/null, the note is not created. 15 - * 16 - * @param {Object} notes - Current notes map { cellId: text } 17 - * @param {string} cellId - Cell identifier (e.g., 'A1') 18 - * @param {string|null} text - Note text 19 - * @returns {Object} Updated notes map 20 - */ 21 - export function createNote(notes, cellId, text) { 22 - const result = { ...notes }; 23 - if (text === null || text === undefined) return result; 24 - const trimmed = text.trim(); 25 - if (trimmed === '') return result; 26 - result[cellId] = trimmed; 27 - return result; 28 - } 29 - 30 - /** 31 - * Update an existing note (or create if missing). 32 - * If the updated text is empty, the note is deleted. 33 - * 34 - * @param {Object} notes - Current notes map 35 - * @param {string} cellId - Cell identifier 36 - * @param {string} text - New note text 37 - * @returns {Object} Updated notes map 38 - */ 39 - export function updateNote(notes, cellId, text) { 40 - const result = { ...notes }; 41 - const trimmed = (text || '').trim(); 42 - if (trimmed === '') { 43 - delete result[cellId]; 44 - return result; 45 - } 46 - result[cellId] = trimmed; 47 - return result; 48 - } 49 - 50 - /** 51 - * Delete a note from a cell. 52 - * 53 - * @param {Object} notes - Current notes map 54 - * @param {string} cellId - Cell identifier 55 - * @returns {Object} Updated notes map 56 - */ 57 - export function deleteNote(notes, cellId) { 58 - const result = { ...notes }; 59 - delete result[cellId]; 60 - return result; 61 - } 62 - 63 - /** 64 - * Get the note text for a cell. 65 - * 66 - * @param {Object} notes - Notes map 67 - * @param {string} cellId - Cell identifier 68 - * @returns {string|null} Note text or null 69 - */ 70 - export function getNote(notes, cellId) { 71 - return notes[cellId] ?? null; 72 - } 73 - 74 - /** 75 - * Check if a cell has a note. 76 - * 77 - * @param {Object} notes - Notes map 78 - * @param {string} cellId - Cell identifier 79 - * @returns {boolean} 80 - */ 81 - export function hasNote(notes, cellId) { 82 - return cellId in notes; 83 - } 84 - 85 - /** 86 - * Get all cell IDs that have notes. 87 - * 88 - * @param {Object} notes - Notes map 89 - * @returns {string[]} Array of cell IDs 90 - */ 91 - export function getAllNotes(notes) { 92 - return Object.keys(notes); 93 - }
+70
src/sheets/cell-notes.ts
··· 1 + /** 2 + * Cell Notes — plain text annotations on individual cells. 3 + * 4 + * Notes are displayed as hover tooltips with a small triangle 5 + * indicator in the top-right corner of the cell. In the real app, 6 + * the notes object is backed by a Yjs Map for collaboration sync. 7 + * 8 + * These functions operate on a plain object { cellId: noteText } 9 + * and return a new object (immutable pattern for testability). 10 + */ 11 + 12 + import type { NotesMap } from './types.js'; 13 + 14 + /** 15 + * Create or set a note on a cell. Trims whitespace. 16 + * If text is empty/null, the note is not created. 17 + */ 18 + export function createNote(notes: NotesMap, cellId: string, text: string | null): NotesMap { 19 + const result = { ...notes }; 20 + if (text === null || text === undefined) return result; 21 + const trimmed = text.trim(); 22 + if (trimmed === '') return result; 23 + result[cellId] = trimmed; 24 + return result; 25 + } 26 + 27 + /** 28 + * Update an existing note (or create if missing). 29 + * If the updated text is empty, the note is deleted. 30 + */ 31 + export function updateNote(notes: NotesMap, cellId: string, text: string): NotesMap { 32 + const result = { ...notes }; 33 + const trimmed = (text || '').trim(); 34 + if (trimmed === '') { 35 + delete result[cellId]; 36 + return result; 37 + } 38 + result[cellId] = trimmed; 39 + return result; 40 + } 41 + 42 + /** 43 + * Delete a note from a cell. 44 + */ 45 + export function deleteNote(notes: NotesMap, cellId: string): NotesMap { 46 + const result = { ...notes }; 47 + delete result[cellId]; 48 + return result; 49 + } 50 + 51 + /** 52 + * Get the note text for a cell. 53 + */ 54 + export function getNote(notes: NotesMap, cellId: string): string | null { 55 + return notes[cellId] ?? null; 56 + } 57 + 58 + /** 59 + * Check if a cell has a note. 60 + */ 61 + export function hasNote(notes: NotesMap, cellId: string): boolean { 62 + return cellId in notes; 63 + } 64 + 65 + /** 66 + * Get all cell IDs that have notes. 67 + */ 68 + export function getAllNotes(notes: NotesMap): string[] { 69 + return Object.keys(notes); 70 + }
+6 -14
src/sheets/cell-styles.js src/sheets/cell-styles.ts
··· 5 5 * These are consumed by the main sheets renderer. 6 6 */ 7 7 8 + import type { BorderStyle } from './types.js'; 9 + 8 10 /** 9 11 * Build a CSS border style string from a borders object. 10 - * @param {{ top?: string, bottom?: string, left?: string, right?: string } | null} borders 11 - * @returns {string} 12 12 */ 13 - export function buildBorderStyle(borders) { 13 + export function buildBorderStyle(borders: BorderStyle | null | undefined): string { 14 14 if (!borders) return ''; 15 15 let style = ''; 16 16 if (borders.top) style += 'border-top:' + borders.top + ';'; ··· 22 22 23 23 /** 24 24 * Generate a borders object from a named preset. 25 - * @param {string | null} preset - 'all', 'outline', 'none', 'top', 'bottom', 'left', 'right' 26 - * @param {string} borderValue - CSS border shorthand, e.g. '1px solid #999' 27 - * @returns {{ top?: string, bottom?: string, left?: string, right?: string }} 28 25 */ 29 - export function applyBorderPreset(preset, borderValue) { 26 + export function applyBorderPreset(preset: string | null, borderValue: string): BorderStyle { 30 27 if (!preset) return {}; 31 28 switch (preset) { 32 29 case 'all': ··· 54 51 55 52 /** 56 53 * Get CSS style string for text wrap state. 57 - * @param {boolean | null | undefined} wrap 58 - * @returns {string} 59 54 */ 60 - export function getWrapStyle(wrap) { 55 + export function getWrapStyle(wrap: boolean | null | undefined): string { 61 56 if (wrap) { 62 57 return 'white-space:normal;word-wrap:break-word;overflow-wrap:break-word;'; 63 58 } ··· 66 61 67 62 /** 68 63 * Get the CSS class name for striped rows. 69 - * @param {number} row - 1-based row number 70 - * @param {boolean | null | undefined} stripedEnabled 71 - * @returns {string} 72 64 */ 73 - export function getStripedRowClass(row, stripedEnabled) { 65 + export function getStripedRowClass(row: number, stripedEnabled: boolean | null | undefined): string { 74 66 if (!stripedEnabled) return ''; 75 67 return row % 2 === 0 ? 'striped-row' : ''; 76 68 }
+48 -33
src/sheets/charts.js src/sheets/charts.ts
··· 6 6 */ 7 7 8 8 import { parseRef, colToLetter, letterToCol } from './formulas.js'; 9 + import type { ChartConfig, ChartValidationResult, DataRange, TransformedChartData, ChartDataset, CellValue } from './types.js'; 9 10 10 11 // ---- Supported chart types ---- 11 - export const CHART_TYPES = ['bar', 'line', 'pie', 'scatter']; 12 + export const CHART_TYPES: readonly string[] = ['bar', 'line', 'pie', 'scatter']; 12 13 13 14 // ---- Configuration validation ---- 14 15 15 16 /** 16 17 * Validate a chart configuration object. 17 - * @param {object} config - { type, range, title?, xAxisLabel?, yAxisLabel? } 18 - * @returns {{ valid: boolean, errors: string[] }} 19 18 */ 20 - export function validateChartConfig(config) { 21 - const errors = []; 19 + export function validateChartConfig(config: ChartConfig): ChartValidationResult { 20 + const errors: string[] = []; 22 21 23 22 if (!config.type || !CHART_TYPES.includes(config.type)) { 24 23 errors.push(config.type ? `Invalid chart type: ${config.type}` : 'Missing chart type'); ··· 38 37 /** 39 38 * Parse a range string like "A1:D10" into start/end col/row. 40 39 * Normalizes so start <= end. Returns null if invalid. 41 - * @param {string} rangeStr 42 - * @returns {{ startCol, startRow, endCol, endRow } | null} 43 40 */ 44 - export function parseDataRange(rangeStr) { 41 + export function parseDataRange(rangeStr: string | null | undefined): DataRange | null { 45 42 if (!rangeStr || typeof rangeStr !== 'string') return null; 46 43 const match = rangeStr.trim().match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i); 47 44 if (!match) return null; ··· 63 60 64 61 /** 65 62 * Extract a 2D grid of cell values from the spreadsheet using a range string. 66 - * @param {string} rangeStr - e.g. "A1:D10" 67 - * @param {(id: string) => any} getCellValue - Resolver returning cell display value 68 - * @returns {any[][]} - 2D array of values 69 63 */ 70 - export function extractChartData(rangeStr, getCellValue) { 64 + export function extractChartData(rangeStr: string, getCellValue: (id: string) => CellValue | ''): (CellValue | '')[][] { 71 65 const range = parseDataRange(rangeStr); 72 66 if (!range) return []; 73 67 74 - const data = []; 68 + const data: (CellValue | '')[][] = []; 75 69 for (let r = range.startRow; r <= range.endRow; r++) { 76 - const row = []; 70 + const row: (CellValue | '')[] = []; 77 71 for (let c = range.startCol; c <= range.endCol; c++) { 78 72 const id = colToLetter(c) + r; 79 73 row.push(getCellValue(id)); ··· 90 84 * A header row is one where all values are strings (non-numeric, non-empty) 91 85 * and there is at least one non-empty value. 92 86 */ 93 - function hasHeaderRow(data) { 87 + function hasHeaderRow(data: (CellValue | '')[][]): boolean { 94 88 if (data.length < 2) return false; 95 89 const firstRow = data[0]; 96 90 const nonEmpty = firstRow.filter(v => v !== '' && v !== null && v !== undefined); ··· 102 96 103 97 /** 104 98 * Transform extracted 2D data into a Chart.js-compatible structure. 105 - * @param {any[][]} rawData - 2D array (may include header row) 106 - * @param {{ type: string, title?: string }} config 107 - * @returns {{ labels: any[], datasets: { label: string, data: any[] }[] }} 108 99 */ 109 - export function transformChartData(rawData, config) { 100 + export function transformChartData(rawData: (CellValue | '')[][], config: { type: string; title?: string }): TransformedChartData { 110 101 if (!rawData || rawData.length === 0) { 111 102 return { labels: [], datasets: [] }; 112 103 } ··· 123 114 124 115 // Single-column data: labels are row indices, data is the column values 125 116 if (colCount === 1) { 126 - const labels = dataRows.map((_, i) => i + 1); 127 - const data = dataRows.map(row => row[0]); 117 + const labels = dataRows.map((_: (CellValue | '')[], i: number) => i + 1); 118 + const data = dataRows.map((row: (CellValue | '')[]) => row[0]); 128 119 return { 129 120 labels, 130 121 datasets: [{ ··· 136 127 137 128 // Scatter chart: first column is X, second column is Y 138 129 if (config.type === 'scatter') { 139 - const points = dataRows.map(row => ({ 130 + const points = dataRows.map((row: (CellValue | '')[]) => ({ 140 131 x: row[0], 141 132 y: row[1], 142 133 })); ··· 150 141 } 151 142 152 143 // Bar/Line/Pie: first column is labels, remaining columns are datasets 153 - const labels = dataRows.map(row => row[0]); 144 + const labels = dataRows.map((row: (CellValue | '')[]) => row[0]); 154 145 155 146 // Pie chart: only use first data column 156 147 if (config.type === 'pie') { 157 - const data = dataRows.map(row => row[1]); 148 + const data = dataRows.map((row: (CellValue | '')[]) => row[1]); 158 149 return { 159 150 labels, 160 151 datasets: [{ ··· 165 156 } 166 157 167 158 // Bar/Line: each column after the first is a dataset 168 - const datasets = []; 159 + const datasets: ChartDataset[] = []; 169 160 for (let c = 1; c < colCount; c++) { 170 161 datasets.push({ 171 162 label: headerRow ? String(headerRow[c]) : `Series ${c}`, 172 - data: dataRows.map(row => row[c]), 163 + data: dataRows.map((row: (CellValue | '')[]) => row[c]), 173 164 }); 174 165 } 175 166 ··· 177 168 } 178 169 179 170 // ---- Default color palette for charts ---- 180 - export const CHART_COLORS = [ 171 + export const CHART_COLORS: readonly string[] = [ 181 172 'rgba(94, 163, 224, 0.8)', // blue 182 173 'rgba(224, 108, 94, 0.8)', // red 183 174 'rgba(94, 196, 138, 0.8)', // green ··· 188 179 'rgba(196, 166, 94, 0.8)', // gold 189 180 ]; 190 181 182 + interface ChartJsScaleTitle { 183 + display: boolean; 184 + text: string; 185 + } 186 + 187 + interface ChartJsScale { 188 + x: { title: ChartJsScaleTitle }; 189 + y: { title: ChartJsScaleTitle }; 190 + } 191 + 192 + interface ChartJsConfig { 193 + type: string; 194 + data: { 195 + labels: (CellValue | '' | number)[]; 196 + datasets: ChartDataset[]; 197 + }; 198 + options: { 199 + responsive: boolean; 200 + maintainAspectRatio: boolean; 201 + plugins: { 202 + title: { display: boolean; text: string }; 203 + legend: { display: boolean }; 204 + }; 205 + scales?: ChartJsScale; 206 + }; 207 + } 208 + 191 209 /** 192 210 * Build a full Chart.js configuration object. 193 - * @param {{ type, range, title, xAxisLabel, yAxisLabel }} chartConfig 194 - * @param {{ labels, datasets }} transformedData 195 - * @returns {object} Chart.js config 196 211 */ 197 - export function buildChartJsConfig(chartConfig, transformedData) { 212 + export function buildChartJsConfig(chartConfig: ChartConfig, transformedData: TransformedChartData): ChartJsConfig { 198 213 const { type, title, xAxisLabel, yAxisLabel } = chartConfig; 199 214 200 215 // Assign colors to datasets ··· 211 226 // Pie charts get a multi-color single dataset 212 227 if (type === 'pie' && datasets.length === 1) { 213 228 datasets[0].backgroundColor = transformedData.labels.map( 214 - (_, i) => CHART_COLORS[i % CHART_COLORS.length] 229 + (_: CellValue | '' | number, i: number) => CHART_COLORS[i % CHART_COLORS.length] 215 230 ); 216 231 datasets[0].borderColor = 'rgba(255,255,255,0.8)'; 217 232 } 218 233 219 - const config = { 234 + const config: ChartJsConfig = { 220 235 type: type === 'scatter' ? 'scatter' : type, 221 236 data: { 222 237 labels: transformedData.labels,
+7 -13
src/sheets/conditional-format.js src/sheets/conditional-format.ts
··· 17 17 * Multiple rules per sheet, evaluated in order (first match wins). 18 18 */ 19 19 20 + import type { CfRule, CfStyleResult } from './types.js'; 21 + 20 22 /** 21 23 * Evaluate a single conditional formatting rule against a cell value. 22 - * @param {any} cellValue - The cell's display value 23 - * @param {{ type: string, value?: any, value2?: any }} rule 24 - * @returns {boolean} 25 24 */ 26 - export function evaluateRule(cellValue, rule) { 25 + export function evaluateRule(cellValue: unknown, rule: CfRule): boolean { 27 26 if (!rule || !rule.type) return false; 28 27 29 28 switch (rule.type) { ··· 78 77 /** 79 78 * Evaluate an ordered array of rules against a cell value. 80 79 * Returns the style from the first matching rule, or null. 81 - * @param {any} cellValue 82 - * @param {Array<{ type: string, value?: any, value2?: any, bgColor?: string, textColor?: string }>} rules 83 - * @returns {{ bgColor?: string, textColor?: string } | null} 84 80 */ 85 - export function evaluateRules(cellValue, rules) { 81 + export function evaluateRules(cellValue: unknown, rules: CfRule[]): CfStyleResult | null { 86 82 if (!Array.isArray(rules) || rules.length === 0) return null; 87 83 for (const rule of rules) { 88 84 if (evaluateRule(cellValue, rule)) { 89 - const style = {}; 85 + const style: CfStyleResult = {}; 90 86 if (rule.bgColor) style.bgColor = rule.bgColor; 91 87 if (rule.textColor) style.textColor = rule.textColor; 92 88 return style; ··· 97 93 98 94 /** 99 95 * Build a CSS style string from conditional formatting result. 100 - * @param {{ bgColor?: string, textColor?: string } | null} cfResult 101 - * @returns {string} 102 96 */ 103 - export function buildCfStyle(cfResult) { 97 + export function buildCfStyle(cfResult: CfStyleResult | null): string { 104 98 if (!cfResult) return ''; 105 99 let style = ''; 106 100 if (cfResult.bgColor) style += 'background:' + cfResult.bgColor + ';'; ··· 111 105 /** 112 106 * Safely convert a value to a number, returning null if not numeric. 113 107 */ 114 - function toNumber(v) { 108 + function toNumber(v: unknown): number | null { 115 109 if (v === null || v === undefined || v === '') return null; 116 110 if (typeof v === 'number') return v; 117 111 const n = Number(v);
+9 -9
src/sheets/cross-sheet.js src/sheets/cross-sheet.ts
··· 6 6 * Quoted names (single quotes) allow spaces and special characters. 7 7 */ 8 8 9 + import type { CrossSheetResolver, CellValue } from './types.js'; 10 + 11 + interface ParsedCrossSheetRef { 12 + sheetName: string; 13 + ref: string; 14 + } 15 + 9 16 /** 10 17 * Parse a cross-sheet reference string. 11 - * @param {string} ref - e.g. "Sheet2!A1", "'My Sheet'!A1:B5" 12 - * @returns {{ sheetName: string, ref: string } | null} 13 18 */ 14 - export function parseCrossSheetRef(ref) { 19 + export function parseCrossSheetRef(ref: string): ParsedCrossSheetRef | null { 15 20 // Quoted sheet name: 'Sheet Name'!A1 or 'Sheet Name'!A1:B5 16 21 const quotedMatch = ref.match(/^'([^']+)'!(.+)$/); 17 22 if (quotedMatch) { ··· 19 24 } 20 25 21 26 // Unquoted sheet name: Sheet2!A1 or Sheet2!A1:B5 22 - // Sheet name is anything before `!` that isn't just a cell ref 23 27 const unquotedMatch = ref.match(/^([A-Za-z_][A-Za-z0-9_]*)!(.+)$/); 24 28 if (unquotedMatch) { 25 29 return { sheetName: unquotedMatch[1], ref: unquotedMatch[2] }; ··· 30 34 31 35 /** 32 36 * Resolve a cross-sheet cell reference using a resolver. 33 - * @param {string} sheetName - Target sheet name 34 - * @param {string} cellRef - Cell reference (e.g. "A1") 35 - * @param {object} resolver - Has getSheetCellValue(sheetName, cellRef), sheetExists(name) 36 - * @returns {any} Cell value or '#REF!' if sheet doesn't exist 37 37 */ 38 - export function resolveCrossSheetRef(sheetName, cellRef, resolver) { 38 + export function resolveCrossSheetRef(sheetName: string, cellRef: string, resolver: CrossSheetResolver): CellValue | '' | string { 39 39 if (!resolver.sheetExists(sheetName)) { 40 40 return '#REF!'; 41 41 }
+6 -11
src/sheets/data-validation.js src/sheets/data-validation.ts
··· 10 10 * { type: string, value: string, value2?: string, items?: string[] } 11 11 */ 12 12 13 + import type { ValidationRule, ValidationResult } from './types.js'; 14 + 13 15 /** 14 16 * Parse a comma-separated list string into trimmed items. 15 - * @param {string} listStr 16 - * @returns {string[]} 17 17 */ 18 - export function parseListItems(listStr) { 18 + export function parseListItems(listStr: string | undefined): string[] { 19 19 if (!listStr || typeof listStr !== 'string') return []; 20 20 return listStr.split(',').map(s => s.trim()).filter(s => s !== ''); 21 21 } 22 22 23 23 /** 24 24 * Validate a cell value against a validation rule. 25 - * @param {any} cellValue 26 - * @param {{ type: string, value?: string, value2?: string, items?: string[] }} rule 27 - * @returns {{ valid: boolean, message?: string }} 28 25 */ 29 - export function validateCell(cellValue, rule) { 26 + export function validateCell(cellValue: unknown, rule: ValidationRule | null): ValidationResult { 30 27 if (!rule || !rule.type) return { valid: true }; 31 28 32 29 switch (rule.type) { ··· 85 82 86 83 /** 87 84 * Get dropdown items for a list-type validation rule. 88 - * @param {{ type: string, value?: string, items?: string[] }} rule 89 - * @returns {string[]} 90 85 */ 91 - export function getDropdownItems(rule) { 86 + export function getDropdownItems(rule: ValidationRule | null): string[] { 92 87 if (!rule || rule.type !== 'list') return []; 93 88 return rule.items || parseListItems(rule.value); 94 89 } ··· 96 91 /** 97 92 * Safely convert a value to a number, returning null if not numeric. 98 93 */ 99 - function toNumber(v) { 94 + function toNumber(v: unknown): number | null { 100 95 if (v === null || v === undefined || v === '') return null; 101 96 if (typeof v === 'number') return v; 102 97 const n = Number(v);
+29 -35
src/sheets/drag-fill.js src/sheets/drag-fill.ts
··· 7 7 */ 8 8 9 9 import { colToLetter, letterToCol } from './formulas.js'; 10 + import type { FillPattern, FillDirection } from './types.js'; 10 11 11 12 // --- Pattern type constants --- 12 13 export const PATTERN_TYPES = { ··· 14 15 DATE_INCREMENT: 'date_increment', 15 16 FORMULA_ADJUST: 'formula_adjust', 16 17 TEXT_REPEAT: 'text_repeat', 17 - }; 18 + } as const; 19 + 20 + interface FormulaValue { 21 + f: string; 22 + [key: string]: unknown; 23 + } 24 + 25 + type SourceValue = string | number | FormulaValue | null | undefined; 18 26 19 27 /** 20 28 * Try to parse a value as a date string. Returns a Date if valid, null otherwise. 21 29 * Recognises ISO-style dates (YYYY-MM-DD) and common locale strings. 22 30 */ 23 - function tryParseDate(v) { 31 + function tryParseDate(v: unknown): Date | null { 24 32 if (typeof v !== 'string') return null; 25 33 // Quick sanity: must contain digits and dashes/slashes 26 34 if (!/\d{4}[-/]\d{1,2}[-/]\d{1,2}/.test(v)) return null; ··· 33 41 * Format a Date as YYYY-MM-DD (ISO date string without time). 34 42 * Uses UTC methods to avoid timezone issues with date-only strings. 35 43 */ 36 - function formatDateISO(d) { 44 + function formatDateISO(d: Date): string { 37 45 const yyyy = d.getUTCFullYear(); 38 46 const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); 39 47 const dd = String(d.getUTCDate()).padStart(2, '0'); ··· 45 53 * 46 54 * Values can be plain primitives (number, string) or cell-data objects 47 55 * with { v, f } when the cell contains a formula. 48 - * 49 - * @param {Array<any>} values - Source cell values 50 - * @returns {{ type: string, step?: number, stepMs?: number }} 51 56 */ 52 - export function detectPattern(values) { 57 + export function detectPattern(values: SourceValue[]): FillPattern { 53 58 if (!values || values.length === 0) { 54 59 return { type: PATTERN_TYPES.TEXT_REPEAT }; 55 60 } 56 61 57 62 // Check if any value is a formula object 58 - if (values.some(v => v && typeof v === 'object' && v.f)) { 63 + if (values.some(v => v && typeof v === 'object' && (v as FormulaValue).f)) { 59 64 return { type: PATTERN_TYPES.FORMULA_ADJUST }; 60 65 } 61 66 ··· 66 71 return { type: PATTERN_TYPES.NUMBER_INCREMENT, step: 1 }; 67 72 } 68 73 // Compute step from first two values 69 - const step = values[1] - values[0]; 74 + const step = (values[1] as number) - (values[0] as number); 70 75 // Verify all subsequent steps are the same (arithmetic sequence) 71 76 if (values.length === 2) { 72 77 return { type: PATTERN_TYPES.NUMBER_INCREMENT, step }; 73 78 } 74 79 const isArithmetic = values.every((v, i) => { 75 80 if (i === 0) return true; 76 - return Math.abs((v - values[i - 1]) - step) < 1e-10; 81 + return Math.abs(((v as number) - (values[i - 1] as number)) - step) < 1e-10; 77 82 }); 78 83 if (isArithmetic) { 79 84 return { type: PATTERN_TYPES.NUMBER_INCREMENT, step }; ··· 89 94 if (values.length === 1) { 90 95 return { type: PATTERN_TYPES.DATE_INCREMENT, stepMs: 86400000 }; // 1 day 91 96 } 92 - const stepMs = dates[1].getTime() - dates[0].getTime(); 97 + const stepMs = dates[1]!.getTime() - dates[0]!.getTime(); 93 98 if (values.length === 2) { 94 99 return { type: PATTERN_TYPES.DATE_INCREMENT, stepMs }; 95 100 } 96 101 // Verify consistent step 97 102 const isConsistent = dates.every((d, i) => { 98 103 if (i === 0) return true; 99 - return (d.getTime() - dates[i - 1].getTime()) === stepMs; 104 + return (d!.getTime() - dates[i - 1]!.getTime()) === stepMs; 100 105 }); 101 106 if (isConsistent) { 102 107 return { type: PATTERN_TYPES.DATE_INCREMENT, stepMs }; ··· 108 113 109 114 /** 110 115 * Generate fill values based on detected pattern. 111 - * 112 - * @param {Array<any>} sourceValues - Original source values 113 - * @param {{ type: string, step?: number, stepMs?: number }} pattern 114 - * @param {number} count - Number of new values to generate 115 - * @param {'forward' | 'backward'} direction 116 - * @returns {Array<any>} Generated fill values 117 116 */ 118 - export function generateFillValues(sourceValues, pattern, count, direction) { 117 + export function generateFillValues(sourceValues: SourceValue[], pattern: FillPattern, count: number, direction: FillDirection): SourceValue[] { 119 118 if (count <= 0) return []; 120 119 121 120 const forward = direction === 'forward'; 122 121 123 122 switch (pattern.type) { 124 123 case PATTERN_TYPES.NUMBER_INCREMENT: { 125 - const step = pattern.step; 126 - const lastVal = sourceValues[sourceValues.length - 1]; 127 - const firstVal = sourceValues[0]; 128 - const result = []; 124 + const step = pattern.step!; 125 + const lastVal = sourceValues[sourceValues.length - 1] as number; 126 + const firstVal = sourceValues[0] as number; 127 + const result: number[] = []; 129 128 for (let i = 1; i <= count; i++) { 130 129 if (forward) { 131 130 result.push(lastVal + step * i); ··· 139 138 } 140 139 141 140 case PATTERN_TYPES.DATE_INCREMENT: { 142 - const stepMs = pattern.stepMs; 143 - const dates = sourceValues.map(v => new Date(v)); 141 + const stepMs = pattern.stepMs!; 142 + const dates = sourceValues.map(v => new Date(v as string)); 144 143 const lastDate = dates[dates.length - 1]; 145 144 const firstDate = dates[0]; 146 - const result = []; 145 + const result: string[] = []; 147 146 for (let i = 1; i <= count; i++) { 148 147 if (forward) { 149 148 result.push(formatDateISO(new Date(lastDate.getTime() + stepMs * i))); ··· 156 155 } 157 156 158 157 case PATTERN_TYPES.TEXT_REPEAT: { 159 - const result = []; 158 + const result: SourceValue[] = []; 160 159 for (let i = 0; i < count; i++) { 161 160 result.push(sourceValues[i % sourceValues.length]); 162 161 } ··· 166 165 case PATTERN_TYPES.FORMULA_ADJUST: { 167 166 // Formula fill is handled by adjustFormulaRef in the caller 168 167 // Return source values cyclically as placeholders 169 - const result = []; 168 + const result: SourceValue[] = []; 170 169 for (let i = 0; i < count; i++) { 171 170 result.push(sourceValues[i % sourceValues.length]); 172 171 } ··· 181 180 /** 182 181 * Adjust cell references in a formula by a given row/column offset. 183 182 * Respects absolute references ($A$1 = no adjustment). 184 - * 185 - * @param {string} formula - Formula string (without leading '=') 186 - * @param {number} dCol - Column offset (positive = right) 187 - * @param {number} dRow - Row offset (positive = down) 188 - * @returns {string} Adjusted formula 189 183 */ 190 - export function adjustFormulaRef(formula, dCol, dRow) { 184 + export function adjustFormulaRef(formula: string, dCol: number, dRow: number): string { 191 185 // Match cell references: optional $ before letters, optional $ before digits 192 186 // Pattern: (\$?)([A-Z]+)(\$?)(\d+) 193 - return formula.replace(/(\$?)([A-Z]+)(\$?)(\d+)/gi, (match, colAbs, colLetters, rowAbs, rowDigits) => { 187 + return formula.replace(/(\$?)([A-Z]+)(\$?)(\d+)/gi, (_match: string, colAbs: string, colLetters: string, rowAbs: string, rowDigits: string) => { 194 188 const colFixed = colAbs === '$'; 195 189 const rowFixed = rowAbs === '$'; 196 190
+14 -21
src/sheets/filter.js src/sheets/filter.ts
··· 5 5 * No DOM dependencies — the UI integration is in main.js. 6 6 */ 7 7 8 + import type { FilterState } from './types.js'; 9 + 10 + interface FilterRow { 11 + _row: number; 12 + [col: number]: unknown; 13 + } 14 + 8 15 /** 9 16 * Get unique values for a given column across all rows. 10 17 * Values are stringified for consistent comparison in filter UI. 11 - * @param {object[]} rows - Array of row objects ({ _row, [colNum]: value }) 12 - * @param {number} col - Column number (1-based) 13 - * @returns {string[]} - Sorted unique string values 14 18 */ 15 - export function getUniqueColumnValues(rows, col) { 16 - const seen = new Set(); 19 + export function getUniqueColumnValues(rows: FilterRow[], col: number): string[] { 20 + const seen = new Set<string>(); 17 21 for (const row of rows) { 18 22 const val = row[col]; 19 23 seen.add(val !== undefined && val !== null ? String(val) : ''); ··· 23 27 24 28 /** 25 29 * Build initial filter state for a column with all values checked. 26 - * @param {object[]} rows - Array of row objects 27 - * @param {number} col - Column number 28 - * @returns {Object<string, boolean>} - Map of value -> true 29 30 */ 30 - export function buildFilterState(rows, col) { 31 + export function buildFilterState(rows: FilterRow[], col: number): Record<string, boolean> { 31 32 const values = getUniqueColumnValues(rows, col); 32 - const state = {}; 33 + const state: Record<string, boolean> = {}; 33 34 for (const val of values) { 34 35 state[val] = true; 35 36 } ··· 41 42 * 42 43 * A row is visible if, for every filtered column, the row's value in that 43 44 * column is checked (true) in the filter state. 44 - * 45 - * @param {object[]} rows - Array of row objects 46 - * @param {Object<number, Object<string, boolean>>} filters - { colNum: { value: checked } } 47 - * @returns {object[]} - Filtered rows 48 45 */ 49 - export function applyFilters(rows, filters) { 46 + export function applyFilters(rows: FilterRow[], filters: FilterState): FilterRow[] { 50 47 const activeColumns = Object.keys(filters).map(Number); 51 48 if (activeColumns.length === 0) return rows; 52 49 ··· 64 61 65 62 /** 66 63 * Remove the filter for a specific column. 67 - * @param {object} filters - Current filter state 68 - * @param {number} col - Column to clear 69 - * @returns {object} - New filters without the specified column 70 64 */ 71 - export function clearColumnFilter(filters, col) { 65 + export function clearColumnFilter(filters: FilterState, col: number): FilterState { 72 66 const result = { ...filters }; 73 67 delete result[col]; 74 68 return result; ··· 76 70 77 71 /** 78 72 * Remove all filters. 79 - * @returns {object} - Empty filter state 80 73 */ 81 - export function clearAllFilters() { 74 + export function clearAllFilters(): FilterState { 82 75 return {}; 83 76 }
+13 -18
src/sheets/format-painter.js src/sheets/format-painter.ts
··· 5 5 * it to another. Used by the format painter toolbar button. 6 6 */ 7 7 8 + import type { CellStyle } from './types.js'; 9 + 8 10 // --- Known format properties that can be painted --- 9 - export const FORMAT_PROPERTIES = [ 11 + export const FORMAT_PROPERTIES: ReadonlyArray<keyof CellStyle> = [ 10 12 'bold', 11 13 'italic', 12 14 'underline', ··· 20 22 'wrap', 21 23 'fontSize', 22 24 'fontFamily', 23 - ]; 25 + ] as const; 24 26 25 27 /** 26 28 * Extract the paintable format from a cell data object. 27 29 * Only copies known format properties, ignoring values/formulas. 28 - * 29 - * @param {Object|null|undefined} cellData - Cell data with { v, f, s } 30 - * @returns {Object} Style object with only format properties 31 30 */ 32 - export function extractFormat(cellData) { 31 + export function extractFormat(cellData: { s?: CellStyle } | null | undefined): Partial<CellStyle> { 33 32 if (!cellData || !cellData.s) return {}; 34 - const result = {}; 33 + const result: Record<string, unknown> = {}; 35 34 for (const prop of FORMAT_PROPERTIES) { 36 35 if (cellData.s[prop] !== undefined) { 37 36 const val = cellData.s[prop]; 38 37 // Deep clone objects (like borders) 39 38 if (val && typeof val === 'object') { 40 - result[prop] = { ...val }; 39 + result[prop] = { ...(val as Record<string, unknown>) }; 41 40 } else { 42 41 result[prop] = val; 43 42 } 44 43 } 45 44 } 46 - return result; 45 + return result as Partial<CellStyle>; 47 46 } 48 47 49 48 /** ··· 52 51 * 53 52 * Properties in `format` override properties in `existingStyle`. 54 53 * If a property in `format` is undefined, it is removed from the result. 55 - * 56 - * @param {Object|null|undefined} existingStyle - Current cell style 57 - * @param {Object} format - Format to apply (from extractFormat) 58 - * @returns {Object} New style object with format applied 59 54 */ 60 - export function applyFormat(existingStyle, format) { 55 + export function applyFormat(existingStyle: CellStyle | null | undefined, format: Partial<CellStyle>): CellStyle { 61 56 // Start with a copy of existing style 62 - const result = {}; 57 + const result: Record<string, unknown> = {}; 63 58 if (existingStyle) { 64 59 for (const [key, val] of Object.entries(existingStyle)) { 65 60 if (val && typeof val === 'object') { 66 - result[key] = { ...val }; 61 + result[key] = { ...(val as Record<string, unknown>) }; 67 62 } else { 68 63 result[key] = val; 69 64 } ··· 75 70 if (val === undefined) { 76 71 delete result[key]; 77 72 } else if (val && typeof val === 'object') { 78 - result[key] = { ...val }; 73 + result[key] = { ...(val as Record<string, unknown>) }; 79 74 } else { 80 75 result[key] = val; 81 76 } 82 77 } 83 78 84 - return result; 79 + return result as CellStyle; 85 80 }
+6 -16
src/sheets/formula-autocomplete.js src/sheets/formula-autocomplete.ts
··· 5 5 * filtering logic, and keyboard navigation helpers. 6 6 */ 7 7 8 + import type { FormulaFunction } from './types.js'; 9 + 8 10 /** 9 11 * Complete list of formula functions supported by the formula engine. 10 12 * Each entry has a name (uppercase) and a signature hint. 11 13 */ 12 - export const FORMULA_FUNCTIONS = [ 14 + export const FORMULA_FUNCTIONS: ReadonlyArray<FormulaFunction> = [ 13 15 // Math & Stats 14 16 { name: 'SUM', signature: 'SUM(range1, [range2], ...)' }, 15 17 { name: 'AVERAGE', signature: 'AVERAGE(range1, [range2], ...)' }, ··· 91 93 * Filter the function list based on a partial query string. 92 94 * Case insensitive prefix matching. Results are sorted so that 93 95 * exact matches and shorter names appear first. 94 - * 95 - * @param {string} query - The partial function name typed by the user 96 - * @returns {Array<{name: string, signature: string}>} 97 96 */ 98 - export function filterFunctions(query) { 97 + export function filterFunctions(query: string): FormulaFunction[] { 99 98 if (!query) return [...FORMULA_FUNCTIONS]; 100 99 101 100 const upper = query.toUpperCase(); ··· 116 115 /** 117 116 * Navigate the autocomplete dropdown with arrow keys. 118 117 * Wraps around at boundaries. 119 - * 120 - * @param {number} currentIndex - Current selected index (-1 = none) 121 - * @param {number} itemCount - Total number of items 122 - * @param {'up'|'down'} direction 123 - * @returns {number} New selected index 124 118 */ 125 - export function navigateAutocomplete(currentIndex, itemCount, direction) { 119 + export function navigateAutocomplete(currentIndex: number, itemCount: number, direction: 'up' | 'down'): number { 126 120 if (itemCount === 0) return -1; 127 121 128 122 if (direction === 'down') { ··· 136 130 137 131 /** 138 132 * Get the selected function from the filtered list. 139 - * 140 - * @param {number} index - Selected index 141 - * @param {Array} items - Filtered function list 142 - * @returns {{ name: string, signature: string } | null} 143 133 */ 144 - export function getSelectedFunction(index, items) { 134 + export function getSelectedFunction(index: number, items: FormulaFunction[]): FormulaFunction | null { 145 135 if (index < 0 || index >= items.length) return null; 146 136 return items[index]; 147 137 }
+6 -10
src/sheets/formula-highlighter.js src/sheets/formula-highlighter.ts
··· 6 6 * maps exactly back to the original text. 7 7 */ 8 8 9 + import type { HighlightToken, HighlightTokenType } from './types.js'; 10 + 9 11 // Known error values in spreadsheets 10 12 const ERROR_PATTERN = /^#(REF!|N\/A|VALUE!|ERROR!|NAME\?|NULL!|NUM!|DIV\/0!)/; 11 13 ··· 29 31 * Tokenize a formula string for syntax highlighting. 30 32 * Returns tokens with original positions preserved so the highlighted 31 33 * output reconstructs the exact formula text. 32 - * 33 - * @param {string} formula - The formula string (including leading '=') 34 - * @returns {Array<{text: string, type: string, start: number, end: number}>} 35 34 */ 36 - export function tokenizeForHighlighting(formula) { 37 - const tokens = []; 35 + export function tokenizeForHighlighting(formula: string): HighlightToken[] { 36 + const tokens: HighlightToken[] = []; 38 37 let i = 0; 39 38 const s = formula; 40 39 ··· 243 242 /** 244 243 * Escape HTML special characters for safe insertion. 245 244 */ 246 - function escapeHtml(text) { 245 + function escapeHtml(text: string): string { 247 246 return text 248 247 .replace(/&/g, '&amp;') 249 248 .replace(/</g, '&lt;') ··· 254 253 /** 255 254 * Render highlighted formula tokens as an HTML string. 256 255 * Each token is wrapped in a <span> with a class based on its type. 257 - * 258 - * @param {Array<{text: string, type: string, start: number, end: number}>} tokens 259 - * @returns {string} HTML string 260 256 */ 261 - export function renderHighlightedFormula(tokens) { 257 + export function renderHighlightedFormula(tokens: HighlightToken[]): string { 262 258 return tokens.map(t => { 263 259 const escaped = escapeHtml(t.text); 264 260 return `<span class="formula-token-${t.type}">${escaped}</span>`;
+6 -4
src/sheets/formula-tooltip.js src/sheets/formula-tooltip.ts
··· 6 6 * cursor position (counting commas and parens). 7 7 */ 8 8 9 + import type { FunctionMetadataEntry, DetectedFunction, FunctionParam } from './types.js'; 10 + 9 11 /** 10 12 * Complete function metadata for all 57 supported functions. 11 13 * Each entry has a description and per-parameter info. 12 14 */ 13 - export const FUNCTION_METADATA = { 15 + export const FUNCTION_METADATA: Record<string, FunctionMetadataEntry> = { 14 16 // --- Math & Stats --- 15 17 SUM: { 16 18 desc: 'Adds all numbers in a range', ··· 388 390 * @param {number} cursorPosition - The cursor position in the string 389 391 * @returns {{ functionName: string, paramIndex: number } | null} 390 392 */ 391 - export function detectCurrentFunction(formula, cursorPosition) { 393 + export function detectCurrentFunction(formula: string, cursorPosition: number): DetectedFunction | null { 392 394 if (!formula || cursorPosition <= 0) return null; 393 395 394 396 // Work with the portion up to the cursor ··· 472 474 * @param {HTMLElement} anchorElement - The element to position the tooltip near 473 475 * @returns {HTMLElement | null} The tooltip element, or null if function not found 474 476 */ 475 - export function renderTooltip(functionName, paramIndex, anchorElement) { 477 + export function renderTooltip(functionName: string, paramIndex: number, anchorElement: HTMLElement | null): HTMLElement | null { 476 478 const meta = FUNCTION_METADATA[functionName]; 477 479 if (!meta) return null; 478 480 ··· 535 537 /** 536 538 * Remove the tooltip from the DOM. 537 539 */ 538 - export function hideTooltip() { 540 + export function hideTooltip(): void { 539 541 const existing = document.getElementById('formula-tooltip'); 540 542 if (existing) existing.remove(); 541 543 }
+11 -18
src/sheets/formula-tracer.js src/sheets/formula-tracer.ts
··· 10 10 11 11 import { extractRefs } from './formulas.js'; 12 12 13 + interface CellDataMap { 14 + [cellId: string]: { v: unknown; f: string }; 15 + } 16 + 13 17 /** 14 18 * Trace the precedents (inputs) of a cell. 15 19 * Returns the set of cell IDs that the cell's formula directly references. 16 - * 17 - * @param {string} cellId - The cell to trace (e.g. "C1") 18 - * @param {object} cellData - Map of cellId -> { v, f } where f is formula string 19 - * @returns {Set<string>} Set of precedent cell IDs 20 20 */ 21 - export function tracePrecedents(cellId, cellData) { 21 + export function tracePrecedents(cellId: string, cellData: CellDataMap): Set<string> { 22 22 const cell = cellData[cellId]; 23 23 if (!cell || !cell.f) return new Set(); 24 24 return extractRefs(cell.f); ··· 27 27 /** 28 28 * Trace the dependents (outputs) of a cell. 29 29 * Returns the set of cell IDs whose formulas reference the given cell. 30 - * 31 - * @param {string} cellId - The cell to trace (e.g. "A1") 32 - * @param {object} cellData - Map of cellId -> { v, f } 33 - * @returns {Set<string>} Set of dependent cell IDs 34 30 */ 35 - export function traceDependents(cellId, cellData) { 36 - const dependents = new Set(); 31 + export function traceDependents(cellId: string, cellData: CellDataMap): Set<string> { 32 + const dependents = new Set<string>(); 37 33 for (const [id, cell] of Object.entries(cellData)) { 38 34 if (!cell.f) continue; 39 35 const refs = extractRefs(cell.f); ··· 47 43 /** 48 44 * Build a full dependency graph for all cells. 49 45 * Returns both precedent and dependent maps. 50 - * 51 - * @param {object} cellData - Map of cellId -> { v, f } 52 - * @returns {{ precedents: Map<string, Set<string>>, dependents: Map<string, Set<string>> }} 53 46 */ 54 - export function buildDependencyGraph(cellData) { 55 - const precedents = new Map(); // cellId -> Set of cells it depends on 56 - const dependents = new Map(); // cellId -> Set of cells that depend on it 47 + export function buildDependencyGraph(cellData: CellDataMap): { precedents: Map<string, Set<string>>; dependents: Map<string, Set<string>> } { 48 + const precedents = new Map<string, Set<string>>(); // cellId -> Set of cells it depends on 49 + const dependents = new Map<string, Set<string>>(); // cellId -> Set of cells that depend on it 57 50 58 51 for (const [id, cell] of Object.entries(cellData)) { 59 52 if (!cell.f) continue; ··· 66 59 if (!dependents.has(ref)) { 67 60 dependents.set(ref, new Set()); 68 61 } 69 - dependents.get(ref).add(id); 62 + dependents.get(ref)!.add(id); 70 63 } 71 64 } 72 65
+67 -50
src/sheets/formulas.js src/sheets/formulas.ts
··· 5 5 * ranges (A1:B5), and a library of common functions. 6 6 */ 7 7 8 + import type { CellRef, CellValue, CrossSheetResolver, NamedRangesMap, RangeArray, FormatType } from './types.js'; 9 + 8 10 // --- Tokenizer --- 11 + type TokenTypeValue = 'NUMBER' | 'STRING' | 'BOOLEAN' | 'CELL_REF' | 'CROSS_SHEET_REF' | 'RANGE' | 'FUNCTION' | 'IDENTIFIER' | 'OPERATOR' | 'LPAREN' | 'RPAREN' | 'COMMA' | 'COLON' | 'BANG' | 'EOF'; 12 + 13 + interface CrossSheetTokenValue { 14 + sheetName: string; 15 + ref: string; 16 + } 17 + 18 + interface Token { 19 + type: TokenTypeValue; 20 + value?: string | number | boolean | CrossSheetTokenValue; 21 + } 22 + 9 23 const TokenType = { 10 24 NUMBER: 'NUMBER', 11 25 STRING: 'STRING', ··· 24 38 EOF: 'EOF', 25 39 }; 26 40 27 - function tokenize(formula) { 28 - const tokens = []; 41 + function tokenize(formula: string): Token[] { 42 + const tokens: Token[] = []; 29 43 let i = 0; 30 44 const s = formula; 31 45 ··· 171 185 172 186 // --- Parser (recursive descent) --- 173 187 class Parser { 174 - constructor(tokens, getCellValue, crossSheetResolver, namedRanges) { 188 + tokens: Token[]; 189 + pos: number; 190 + getCellValue: (ref: string) => CellValue | ''; 191 + crossSheetResolver: CrossSheetResolver | null; 192 + namedRanges: NamedRangesMap; 193 + _letScope: Record<string, unknown> | null; 194 + 195 + constructor(tokens: Token[], getCellValue: (ref: string) => CellValue | '', crossSheetResolver: CrossSheetResolver | null | undefined, namedRanges: NamedRangesMap | null | undefined) { 175 196 this.tokens = tokens; 176 197 this.pos = 0; 177 198 this.getCellValue = getCellValue; 178 - this.crossSheetResolver = crossSheetResolver; 199 + this.crossSheetResolver = crossSheetResolver || null; 179 200 this.namedRanges = namedRanges || {}; 201 + this._letScope = null; 180 202 } 181 203 182 - peek() { return this.tokens[this.pos]; } 183 - advance() { return this.tokens[this.pos++]; } 204 + peek(): Token { return this.tokens[this.pos]; } 205 + advance(): Token { return this.tokens[this.pos++]; } 184 206 185 - expect(type) { 207 + expect(type: TokenTypeValue): Token { 186 208 const t = this.advance(); 187 209 if (t.type !== type) throw new Error(`Expected ${type}, got ${t.type}`); 188 210 return t; 189 211 } 190 212 191 - parse() { 213 + parse(): unknown { 192 214 const result = this.expression(); 193 215 return result; 194 216 } 195 217 196 218 // expression → comparison 197 - expression() { 219 + expression(): unknown { 198 220 return this.comparison(); 199 221 } 200 222 201 223 // comparison → concat (('=' | '<>' | '<' | '>' | '<=' | '>=') concat)? 202 - comparison() { 224 + comparison(): unknown { 203 225 let left = this.concat(); 204 226 const t = this.peek(); 205 227 if (t.type === TokenType.OPERATOR && ['=', '<>', '<', '>', '<=', '>='].includes(t.value)) { ··· 218 240 } 219 241 220 242 // concat → addition ('&' addition)* 221 - concat() { 243 + concat(): unknown { 222 244 let left = this.addition(); 223 245 while (this.peek().type === TokenType.OPERATOR && this.peek().value === '&') { 224 246 this.advance(); ··· 229 251 } 230 252 231 253 // addition → multiplication (('+' | '-') multiplication)* 232 - addition() { 254 + addition(): unknown { 233 255 let left = this.multiplication(); 234 256 while (this.peek().type === TokenType.OPERATOR && (this.peek().value === '+' || this.peek().value === '-')) { 235 257 const op = this.advance().value; ··· 240 262 } 241 263 242 264 // multiplication → power (('*' | '/') power)* 243 - multiplication() { 265 + multiplication(): unknown { 244 266 let left = this.power(); 245 267 while (this.peek().type === TokenType.OPERATOR && (this.peek().value === '*' || this.peek().value === '/')) { 246 268 const op = this.advance().value; ··· 251 273 } 252 274 253 275 // power → unary ('^' unary)* 254 - power() { 276 + power(): unknown { 255 277 let left = this.unary(); 256 278 while (this.peek().type === TokenType.OPERATOR && this.peek().value === '^') { 257 279 this.advance(); ··· 262 284 } 263 285 264 286 // unary → ('-' | '+') unary | primary 265 - unary() { 287 + unary(): unknown { 266 288 if (this.peek().type === TokenType.OPERATOR && (this.peek().value === '-' || this.peek().value === '+')) { 267 289 const op = this.advance().value; 268 290 const val = this.unary(); ··· 272 294 } 273 295 274 296 // primary → NUMBER | STRING | BOOLEAN | CELL_REF (':' CELL_REF)? | CROSS_SHEET_REF | IDENTIFIER | FUNCTION '(' args ')' | '(' expression ')' 275 - primary() { 297 + primary(): unknown { 276 298 const t = this.peek(); 277 299 278 300 if (t.type === TokenType.NUMBER) { ··· 363 385 } 364 386 365 387 // Function args can be ranges (CELL_REF:CELL_REF), cross-sheet ranges, named ranges, or expressions 366 - parseFunctionArg() { 388 + parseFunctionArg(): unknown { 367 389 // Cross-sheet ref in function arg (range already parsed in tokenizer) 368 390 if (this.peek().type === TokenType.CROSS_SHEET_REF) { 369 391 const saved = this.pos; ··· 405 427 } 406 428 407 429 // Parse LET(name1, value1, [name2, value2, ...], calculation) 408 - parseLet() { 430 + parseLet(): unknown { 409 431 this.expect(TokenType.LPAREN); 410 432 const prevScope = this._letScope ? { ...this._letScope } : null; 411 433 if (!this._letScope) this._letScope = {}; ··· 482 504 } 483 505 } 484 506 485 - resolveRange(startRef, endRef) { 507 + resolveRange(startRef: string, endRef: string): RangeArray { 486 508 const start = parseRef(startRef); 487 509 const end = parseRef(endRef); 488 - const values = []; 510 + const values: RangeArray = []; 489 511 const rowMin = Math.min(start.row, end.row); 490 512 const rowMax = Math.max(start.row, end.row); 491 513 const colMin = Math.min(start.col, end.col); ··· 503 525 } 504 526 505 527 // Resolve a cross-sheet single cell reference 506 - resolveCrossSheetCell(sheetName, cellRef) { 528 + resolveCrossSheetCell(sheetName: string, cellRef: string): unknown { 507 529 if (!this.crossSheetResolver) return '#REF!'; 508 530 if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 509 531 return this.crossSheetResolver.getSheetCellValue(sheetName, cellRef); 510 532 } 511 533 512 534 // Resolve a cross-sheet range reference (e.g. Sheet2!A1:B5) 513 - resolveCrossSheetRange(sheetName, rangeStr) { 535 + resolveCrossSheetRange(sheetName: string, rangeStr: string): RangeArray | string { 514 536 if (!this.crossSheetResolver) return '#REF!'; 515 537 if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 516 538 const parts = rangeStr.split(':'); ··· 518 540 const start = parseRef(parts[0]); 519 541 const end = parseRef(parts[1]); 520 542 if (!start || !end) return '#REF!'; 521 - const values = []; 543 + const values: RangeArray = []; 522 544 const rowMin = Math.min(start.row, end.row); 523 545 const rowMax = Math.max(start.row, end.row); 524 546 const colMin = Math.min(start.col, end.col); ··· 535 557 } 536 558 537 559 // Resolve a named range identifier to its values 538 - resolveNamedRange(name) { 560 + resolveNamedRange(name: string): unknown { 539 561 const key = name.toLowerCase(); 540 562 const entry = this.namedRanges[key]; 541 563 if (!entry) { ··· 553 575 } 554 576 555 577 // --- Function library --- 556 - function callFunction(name, args) { 578 + function callFunction(name: string, args: unknown[]): unknown { 557 579 // Flatten any range arrays in args 558 - const flat = (arr) => arr.flat(Infinity).filter(v => v !== '' && v !== null && v !== undefined); 559 - const nums = (arr) => flat(arr).map(toNum).filter(v => !isNaN(v)); 580 + const flat = (arr: unknown[]): unknown[] => (arr as unknown[]).flat(Infinity).filter(v => v !== '' && v !== null && v !== undefined); 581 + const nums = (arr: unknown[]): number[] => flat(arr).map(toNum).filter(v => !isNaN(v)); 560 582 561 583 switch (name) { 562 584 case 'SUM': return nums(args).reduce((a, b) => a + b, 0); ··· 855 877 } 856 878 857 879 // --- Helpers --- 858 - function toNum(v) { 880 + function toNum(v: unknown): number { 859 881 if (v === '' || v === null || v === undefined) return 0; 860 882 if (typeof v === 'boolean') return v ? 1 : 0; 861 883 if (typeof v === 'number') return v; ··· 863 885 return isNaN(n) ? 0 : n; 864 886 } 865 887 866 - function escapeRegex(s) { 888 + function escapeRegex(s: string): string { 867 889 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 868 890 } 869 891 870 - function matchCriteria(value, criteria) { 892 + function matchCriteria(value: unknown, criteria: unknown): boolean { 871 893 if (typeof criteria === 'string') { 872 894 if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2)); 873 895 if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2)); ··· 881 903 } 882 904 883 905 /** Convert wildcard pattern (* and ?) to a RegExp */ 884 - function wildcardToRegex(pattern) { 906 + function wildcardToRegex(pattern: string): RegExp { 885 907 const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); 886 908 const regexStr = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); 887 909 return new RegExp('^' + regexStr + '$', 'i'); 888 910 } 889 911 890 912 /** matchCriteria with wildcard support for SUMIFS/COUNTIFS/AVERAGEIFS */ 891 - function matchCriteriaWild(value, criteria) { 913 + function matchCriteriaWild(value: unknown, criteria: unknown): boolean { 892 914 if (typeof criteria === 'string') { 893 915 if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2)); 894 916 if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2)); ··· 910 932 return value === criteria; 911 933 } 912 934 913 - function formatValue(num, fmt) { 935 + function formatValue(num: number, fmt: string): string { 914 936 // Simplified TEXT() formatting 915 937 if (fmt === '0') return Math.round(num).toString(); 916 938 if (fmt === '0.00') return num.toFixed(2); ··· 922 944 } 923 945 924 946 // --- VLOOKUP / HLOOKUP helpers --- 925 - function vlookup(needle, flatRange, rows, cols, colIdx, rangeLookup) { 947 + function vlookup(needle: unknown, flatRange: RangeArray, rows: number, cols: number, colIdx: number, rangeLookup: boolean): unknown { 926 948 if (rangeLookup) { 927 949 let bestRow = -1; 928 950 for (let r = 0; r < rows; r++) { ··· 944 966 } 945 967 } 946 968 947 - function hlookup(needle, flatRange, rows, cols, rowIdx, rangeLookup) { 969 + function hlookup(needle: unknown, flatRange: RangeArray, rows: number, cols: number, rowIdx: number, rangeLookup: boolean): unknown { 948 970 if (rangeLookup) { 949 971 let bestCol = -1; 950 972 for (let c = 0; c < cols; c++) { ··· 966 988 } 967 989 } 968 990 969 - function compareValues(a, b) { 991 + function compareValues(a: unknown, b: unknown): number { 970 992 if (typeof a === 'number' && typeof b === 'number') return a - b; 971 993 return String(a).toLowerCase().localeCompare(String(b).toLowerCase()); 972 994 } 973 995 974 - function valuesEqual(a, b) { 996 + function valuesEqual(a: unknown, b: unknown): boolean { 975 997 if (typeof a === 'number' && typeof b === 'number') return a === b; 976 998 if (typeof a === 'number' || typeof b === 'number') { 977 999 const na = toNum(a), nb = toNum(b); ··· 981 1003 } 982 1004 983 1005 // --- Cell reference utilities --- 984 - export function parseRef(ref) { 1006 + export function parseRef(ref: string): CellRef | null { 985 1007 const match = ref.match(/^([A-Z]+)(\d+)$/); 986 1008 if (!match) return null; 987 1009 return { col: letterToCol(match[1]), row: parseInt(match[2]) }; 988 1010 } 989 1011 990 - export function colToLetter(col) { 1012 + export function colToLetter(col: number): string { 991 1013 let result = ''; 992 1014 while (col > 0) { 993 1015 col--; ··· 997 1019 return result; 998 1020 } 999 1021 1000 - export function letterToCol(letter) { 1022 + export function letterToCol(letter: string): number { 1001 1023 let col = 0; 1002 1024 for (let i = 0; i < letter.length; i++) { 1003 1025 col = col * 26 + (letter.charCodeAt(i) - 64); ··· 1005 1027 return col; 1006 1028 } 1007 1029 1008 - export function cellId(col, row) { 1030 + export function cellId(col: number, row: number): string { 1009 1031 return colToLetter(col) + row; 1010 1032 } 1011 1033 1012 1034 // --- Main evaluate function --- 1013 1035 /** 1014 1036 * Evaluate a formula string. 1015 - * @param {string} formula - The formula (without leading '=') 1016 - * @param {(ref: string) => any} getCellValue - Resolver for cell references 1017 - * @param {object} [crossSheetResolver] - Optional resolver for cross-sheet refs 1018 - * @param {object} [namedRanges] - Optional map of lowercase name -> { range, sheet } 1019 - * @returns {any} The computed value 1020 1037 */ 1021 - export function evaluate(formula, getCellValue, crossSheetResolver, namedRanges) { 1038 + export function evaluate(formula: string, getCellValue: (ref: string) => CellValue | '', crossSheetResolver?: CrossSheetResolver | null, namedRanges?: NamedRangesMap | null): unknown { 1022 1039 try { 1023 1040 const tokens = tokenize(formula); 1024 1041 const parser = new Parser(tokens, getCellValue, crossSheetResolver, namedRanges); ··· 1032 1049 * Extract cell references from a formula for dependency tracking. 1033 1050 * Cross-sheet refs are returned as 'SheetName!CellId'. 1034 1051 */ 1035 - export function extractRefs(formula) { 1036 - const refs = new Set(); 1052 + export function extractRefs(formula: string): Set<string> { 1053 + const refs = new Set<string>(); 1037 1054 const tokens = tokenize(formula); 1038 1055 for (let i = 0; i < tokens.length; i++) { 1039 1056 const t = tokens[i]; ··· 1078 1095 /** 1079 1096 * Display-format a cell value based on format type. 1080 1097 */ 1081 - export function formatCell(value, format) { 1098 + export function formatCell(value: unknown, format: string | undefined): string { 1082 1099 if (value === '' || value === null || value === undefined) return ''; 1083 1100 if (typeof value === 'string' && value.startsWith('#')) return value; // Error 1084 1101
+1 -1
src/sheets/index.html
··· 312 312 <div class="cell-note-tooltip" id="cell-note-tooltip" style="display:none"></div> 313 313 </div> 314 314 315 - <script type="module" src="./main.js"></script> 315 + <script type="module" src="./main.ts"></script> 316 316 <script> 317 317 (function() { 318 318 var toggle = document.getElementById('theme-toggle');
+1
src/sheets/main.js src/sheets/main.ts
··· 1 + // @ts-nocheck — DOM entry point; full strict typing planned for follow-up migration 1 2 /** 2 3 * Tools Sheets — E2EE collaborative spreadsheet. 3 4 *
+17 -27
src/sheets/named-ranges.js src/sheets/named-ranges.ts
··· 12 12 * - Must not be TRUE/FALSE 13 13 */ 14 14 15 + import type { NamedRangeEntry, NamedRangeStore } from './types.js'; 16 + 15 17 const CELL_REF_PATTERN = /^[A-Z]{1,3}[0-9]+$/i; 16 18 const VALID_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]*$/; 17 19 const RESERVED_WORDS = ['TRUE', 'FALSE']; 18 20 21 + interface ValidationResult { 22 + valid: boolean; 23 + reason?: string; 24 + } 25 + 19 26 /** 20 27 * Validate a named range name. 21 - * @param {string} name 22 - * @returns {{ valid: boolean, reason?: string }} 23 28 */ 24 - export function validateRangeName(name) { 29 + export function validateRangeName(name: string): ValidationResult { 25 30 if (!name || !name.trim()) { 26 31 return { valid: false, reason: 'Name cannot be empty' }; 27 32 } ··· 46 51 47 52 /** 48 53 * Create or update a named range. 49 - * @param {object} store - Map-like store (Yjs Map or mock) 50 - * @param {string} name - Range name 51 - * @param {string} range - Cell range string (e.g. "A1:A10") 52 - * @param {string} sheet - Sheet name 53 - * @throws {Error} if name is invalid 54 54 */ 55 - export function createNamedRange(store, name, range, sheet) { 55 + export function createNamedRange(store: NamedRangeStore, name: string, range: string, sheet: string): void { 56 56 const validation = validateRangeName(name); 57 57 if (!validation.valid) { 58 58 throw new Error(`Invalid range name "${name}": ${validation.reason}`); ··· 64 64 65 65 /** 66 66 * Get a named range by name (case-insensitive). 67 - * @param {object} store 68 - * @param {string} name 69 - * @returns {{ name: string, range: string, sheet: string } | null} 70 67 */ 71 - export function getNamedRange(store, name) { 68 + export function getNamedRange(store: NamedRangeStore, name: string): NamedRangeEntry | null { 72 69 const key = name.toLowerCase(); 73 70 if (!store.has(key)) return null; 74 - return JSON.parse(store.get(key)); 71 + return JSON.parse(store.get(key)!) as NamedRangeEntry; 75 72 } 76 73 77 74 /** 78 75 * Delete a named range (case-insensitive). 79 - * @param {object} store 80 - * @param {string} name 81 76 */ 82 - export function deleteNamedRange(store, name) { 77 + export function deleteNamedRange(store: NamedRangeStore, name: string): void { 83 78 const key = name.toLowerCase(); 84 79 store.delete(key); 85 80 } 86 81 87 82 /** 88 83 * List all named ranges. 89 - * @param {object} store 90 - * @returns {Array<{ name: string, range: string, sheet: string }>} 91 84 */ 92 - export function listNamedRanges(store) { 93 - const result = []; 94 - store.forEach((val) => { 95 - result.push(JSON.parse(val)); 85 + export function listNamedRanges(store: NamedRangeStore): NamedRangeEntry[] { 86 + const result: NamedRangeEntry[] = []; 87 + store.forEach((val: string) => { 88 + result.push(JSON.parse(val) as NamedRangeEntry); 96 89 }); 97 90 return result; 98 91 } 99 92 100 93 /** 101 94 * Resolve a named range name to its range and sheet. 102 - * @param {object} store 103 - * @param {string} name 104 - * @returns {{ range: string, sheet: string } | null} 105 95 */ 106 - export function resolveNamedRange(store, name) { 96 + export function resolveNamedRange(store: NamedRangeStore, name: string): { range: string; sheet: string } | null { 107 97 const entry = getNamedRange(store, name); 108 98 if (!entry) return null; 109 99 return { range: entry.range, sheet: entry.sheet };
+14 -24
src/sheets/paste-special.js src/sheets/paste-special.ts
··· 6 6 * formatting only, and transpose. 7 7 */ 8 8 9 + import type { PasteCellData } from './types.js'; 10 + 9 11 // --- Paste mode constants --- 10 12 export const PASTE_MODES = { 11 13 VALUES_ONLY: 'values_only', 12 14 FORMULAS_ONLY: 'formulas_only', 13 15 FORMATTING_ONLY: 'formatting_only', 14 16 TRANSPOSE: 'transpose', 15 - }; 17 + } as const; 16 18 17 19 /** 18 20 * Clone a cell data object (shallow clone of value/formula, deep clone of style). 19 21 */ 20 - function cloneCell(cell) { 22 + function cloneCell(cell: PasteCellData | null): PasteCellData | null { 21 23 if (!cell) return null; 22 - const cloned = { v: cell.v, f: cell.f, s: {} }; 24 + const cloned: PasteCellData = { v: cell.v, f: cell.f, s: {} }; 23 25 if (cell.s) { 24 26 for (const [key, val] of Object.entries(cell.s)) { 25 27 if (val && typeof val === 'object') { 26 - cloned.s[key] = { ...val }; 28 + cloned.s[key] = { ...(val as Record<string, unknown>) }; 27 29 } else { 28 30 cloned.s[key] = val; 29 31 } ··· 34 36 35 37 /** 36 38 * Extract values only from a grid — strips formulas, keeps computed values and styles. 37 - * 38 - * @param {Array<Array<Object|null>>} grid - 2D array of cell data objects 39 - * @returns {Array<Array<Object|null>>} Transformed grid 40 39 */ 41 - export function extractValuesOnly(grid) { 40 + export function extractValuesOnly(grid: (PasteCellData | null)[][]): (PasteCellData | null)[][] { 42 41 return grid.map(row => 43 42 row.map(cell => { 44 43 if (!cell) return null; 45 - const cloned = cloneCell(cell); 44 + const cloned = cloneCell(cell)!; 46 45 cloned.f = ''; 47 46 return cloned; 48 47 }) ··· 52 51 /** 53 52 * Extract formulas only — keeps everything as-is (formulas + values + styles). 54 53 * This is essentially a full copy but explicitly named for the paste-special UI. 55 - * 56 - * @param {Array<Array<Object|null>>} grid - 2D array of cell data objects 57 - * @returns {Array<Array<Object|null>>} Cloned grid 58 54 */ 59 - export function extractFormulasOnly(grid) { 55 + export function extractFormulasOnly(grid: (PasteCellData | null)[][]): (PasteCellData | null)[][] { 60 56 return grid.map(row => 61 57 row.map(cell => { 62 58 if (!cell) return null; ··· 67 63 68 64 /** 69 65 * Extract formatting only — strips values and formulas, keeps styles. 70 - * 71 - * @param {Array<Array<Object|null>>} grid - 2D array of cell data objects 72 - * @returns {Array<Array<Object|null>>} Transformed grid with only styles 73 66 */ 74 - export function extractFormattingOnly(grid) { 67 + export function extractFormattingOnly(grid: (PasteCellData | null)[][]): (PasteCellData | null)[][] { 75 68 return grid.map(row => 76 69 row.map(cell => { 77 70 if (!cell) return null; 78 - const cloned = cloneCell(cell); 71 + const cloned = cloneCell(cell)!; 79 72 cloned.v = ''; 80 73 cloned.f = ''; 81 74 return cloned; ··· 85 78 86 79 /** 87 80 * Transpose a grid — swap rows and columns. 88 - * 89 - * @param {Array<Array<Object|null>>} grid - 2D array of cell data objects 90 - * @returns {Array<Array<Object|null>>} Transposed grid 91 81 */ 92 - export function transposeGrid(grid) { 82 + export function transposeGrid(grid: (PasteCellData | null)[][]): (PasteCellData | null)[][] { 93 83 if (!grid.length || !grid[0] || !grid[0].length) return []; 94 84 const rows = grid.length; 95 85 const cols = grid[0].length; 96 - const result = []; 86 + const result: (PasteCellData | null)[][] = []; 97 87 for (let c = 0; c < cols; c++) { 98 - const newRow = []; 88 + const newRow: (PasteCellData | null)[] = []; 99 89 for (let r = 0; r < rows; r++) { 100 90 newRow.push(cloneCell(grid[r][c])); 101 91 }
+14 -11
src/sheets/range-highlight.js src/sheets/range-highlight.ts
··· 12 12 * The colors are designed to be vibrant enough to stand out on the grid 13 13 * but not overpowering. 14 14 */ 15 - export const RANGE_COLORS = [ 15 + 16 + import type { FormulaRange, ColoredRange, HighlightBorders, CellRef } from './types.js'; 17 + 18 + export const RANGE_COLORS: readonly string[] = [ 16 19 'oklch(0.55 0.2 250)', // blue 17 20 'oklch(0.55 0.18 155)', // green 18 21 'oklch(0.5 0.2 300)', // purple ··· 58 61 * @param {string} formula - The formula (without leading '=') 59 62 * @returns {Array<{ref: string, startIndex: number, endIndex: number}>} 60 63 */ 61 - export function extractFormulaRanges(formula) { 62 - const results = []; 64 + export function extractFormulaRanges(formula: string): FormulaRange[] { 65 + const results: FormulaRange[] = []; 63 66 // Track which positions we've already consumed (for cross-sheet refs) 64 - const consumed = new Set(); 67 + const consumed = new Set<number>(); 65 68 66 69 // 1. Extract quoted cross-sheet refs first: 'Sheet Name'!A1:B5 67 70 { ··· 168 171 * @param {Array<{ref: string, startIndex: number, endIndex: number}>} ranges 169 172 * @returns {Array<{ref: string, startIndex: number, endIndex: number, color: string}>} 170 173 */ 171 - export function assignRangeColors(ranges) { 174 + export function assignRangeColors(ranges: FormulaRange[]): ColoredRange[] { 172 175 if (ranges.length === 0) return []; 173 176 174 - const colorMap = new Map(); 177 + const colorMap = new Map<string, string>(); 175 178 let nextColorIdx = 0; 176 179 177 180 return ranges.map(r => { ··· 181 184 } 182 185 return { 183 186 ...r, 184 - color: colorMap.get(r.ref), 187 + color: colorMap.get(r.ref)!, 185 188 }; 186 189 }); 187 190 } ··· 195 198 * @param {Function} parseRef - Function to parse cell refs into {col, row} 196 199 * @param {Function} colToLetter - Function to convert col number to letter 197 200 */ 198 - export function renderGridHighlights(coloredRanges, gridElement, parseRef, colToLetter) { 201 + export function renderGridHighlights(coloredRanges: ColoredRange[], gridElement: HTMLElement, parseRef: (ref: string) => CellRef | null, colToLetter: (col: number) => string): void { 199 202 clearGridHighlights(); 200 203 201 204 for (const range of coloredRanges) { 202 205 const ref = range.ref; 203 206 // Strip sheet name prefix for grid cell lookup 204 - const localRef = ref.includes('!') ? ref.split('!').pop() : ref; 207 + const localRef = ref.includes('!') ? ref.split('!').pop()! : ref; 205 208 206 209 // Handle range refs (A1:B5) 207 210 if (localRef.includes(':')) { ··· 239 242 /** 240 243 * Add a highlight overlay to a single cell. 241 244 */ 242 - function highlightCell(gridElement, cellRef, color, borders) { 245 + function highlightCell(gridElement: HTMLElement, cellRef: string, color: string, borders: HighlightBorders): void { 243 246 const td = gridElement.querySelector(`td[data-id="${cellRef}"]`); 244 247 if (!td) return; 245 248 ··· 267 270 /** 268 271 * Remove all range highlight overlays from the grid. 269 272 */ 270 - export function clearGridHighlights() { 273 + export function clearGridHighlights(): void { 271 274 document.querySelectorAll('.range-highlight-overlay').forEach(el => el.remove()); 272 275 }
+35 -45
src/sheets/recalc.js src/sheets/recalc.ts
··· 13 13 */ 14 14 15 15 import { extractRefs, evaluate, parseRef, colToLetter } from './formulas.js'; 16 + import type { CellStore, RecalcOptions, CellValue, NamedRangesMap } from './types.js'; 16 17 17 18 // --- Volatile functions --- 18 19 19 - export const VOLATILE_FUNCTIONS = ['NOW', 'TODAY', 'RAND', 'RANDBETWEEN']; 20 + export const VOLATILE_FUNCTIONS: readonly string[] = ['NOW', 'TODAY', 'RAND', 'RANDBETWEEN']; 20 21 21 22 /** 22 23 * Check if a formula contains any volatile function. 23 24 * @param {string} formula - Formula string (without leading '=') 24 25 * @returns {boolean} 25 26 */ 26 - export function isVolatile(formula) { 27 + export function isVolatile(formula: string): boolean { 27 28 const upper = formula.toUpperCase(); 28 29 return VOLATILE_FUNCTIONS.some(fn => upper.includes(fn + '(') || upper.includes(fn + ' (')); 29 30 } 30 31 31 32 // --- Recalculation Engine --- 32 33 33 - /** 34 - * @typedef {Object} CellStore 35 - * @property {(id: string) => {v: any, f: string} | null} get 36 - * @property {(id: string, cell: {v: any, f: string}) => void} set 37 - * @property {(id: string) => boolean} has 38 - * @property {() => IterableIterator<[string, {v: any, f: string}]>} entries 39 - * @property {() => [string, {v: any, f: string}][]} getAllFormulaCells 40 - */ 41 - 42 - /** 43 - * @typedef {Object} RecalcOptions 44 - * @property {(cellId: string) => void} [onEvaluate] - Called when a cell is evaluated (for testing) 45 - * @property {Object} [namedRanges] - Map of lowercase name -> { range, sheet } 46 - * @property {Object} [crossSheetResolver] - Resolver for cross-sheet references 47 - */ 34 + // CellStore and RecalcOptions types are defined in ./types.ts 48 35 49 36 export class RecalcEngine { 50 - /** 51 - * @param {CellStore} store - Cell data store 52 - * @param {RecalcOptions} [options] 53 - */ 54 - constructor(store, options = {}) { 37 + store: CellStore; 38 + options: RecalcOptions; 39 + precedents: Map<string, Set<string>>; 40 + dependents: Map<string, Set<string>>; 41 + volatileCells: Set<string>; 42 + _cyclePaths: string[][]; 43 + 44 + constructor(store: CellStore, options: RecalcOptions = {}) { 55 45 this.store = store; 56 46 this.options = options; 57 47 ··· 72 62 * Build the full dependency graph from scratch. 73 63 * Call this once at initialization or when the entire sheet changes. 74 64 */ 75 - buildFullGraph() { 65 + buildFullGraph(): void { 76 66 this.precedents.clear(); 77 67 this.dependents.clear(); 78 68 this.volatileCells.clear(); ··· 87 77 * Incrementally update the graph for a single cell whose formula changed. 88 78 * @param {string} cellId 89 79 */ 90 - updateCell(cellId) { 80 + updateCell(cellId: string): void { 91 81 // Remove old edges 92 82 this._removeCellEdges(cellId); 93 83 ··· 103 93 * @param {string} cellId 104 94 * @returns {Set<string>} 105 95 */ 106 - getPrecedents(cellId) { 96 + getPrecedents(cellId: string): Set<string> { 107 97 return this.precedents.get(cellId) || new Set(); 108 98 } 109 99 ··· 112 102 * @param {string} cellId 113 103 * @returns {Set<string>} 114 104 */ 115 - getDependents(cellId) { 105 + getDependents(cellId: string): Set<string> { 116 106 return this.dependents.get(cellId) || new Set(); 117 107 } 118 108 ··· 121 111 * Each path is an array of cellIds forming the cycle, e.g. ["A1", "B1", "C1", "A1"]. 122 112 * @returns {string[][]} 123 113 */ 124 - getCyclePaths() { 114 + getCyclePaths(): string[][] { 125 115 return this._cyclePaths; 126 116 } 127 117 ··· 133 123 * @param {string} editedCellId - The cell that was edited 134 124 * @returns {Set<string>} Set of cell IDs whose display values actually changed 135 125 */ 136 - recalculate(editedCellId) { 126 + recalculate(editedCellId: string): Set<string> { 137 127 return this.recalculateMultiple([editedCellId]); 138 128 } 139 129 ··· 142 132 * @param {string[]} editedCellIds - The cells that were edited 143 133 * @returns {Set<string>} Set of cell IDs whose display values actually changed 144 134 */ 145 - recalculateMultiple(editedCellIds) { 135 + recalculateMultiple(editedCellIds: string[]): Set<string> { 146 136 // Step 1: Collect all dirty cells (edited + transitive dependents) 147 137 const dirty = this._collectDirty(editedCellIds); 148 138 ··· 155 145 * Call this on a timer or on any UI interaction. 156 146 * @returns {Set<string>} Set of cell IDs whose display values actually changed 157 147 */ 158 - recalculateVolatile() { 148 + recalculateVolatile(): Set<string> { 159 149 if (this.volatileCells.size === 0) return new Set(); 160 150 return this.recalculateMultiple([...this.volatileCells]); 161 151 } ··· 167 157 * @param {string} cellId 168 158 * @param {string} formula 169 159 */ 170 - _addCellEdges(cellId, formula) { 160 + _addCellEdges(cellId: string, formula: string): void { 171 161 let refs = extractRefs(formula); 172 162 173 163 // Also resolve named ranges to their constituent cells ··· 203 193 * Remove all edges for a cell from the graph. 204 194 * @param {string} cellId 205 195 */ 206 - _removeCellEdges(cellId) { 196 + _removeCellEdges(cellId: string): void { 207 197 const oldPrecs = this.precedents.get(cellId); 208 198 if (oldPrecs) { 209 199 for (const ref of oldPrecs) { ··· 225 215 * @param {Set<string>} existingRefs 226 216 * @returns {Set<string>} 227 217 */ 228 - _resolveNamedRangeRefs(formula, existingRefs) { 218 + _resolveNamedRangeRefs(formula: string, existingRefs: Set<string>): Set<string> { 229 219 const namedRanges = this.options.namedRanges; 230 220 if (!namedRanges) return existingRefs; 231 221 ··· 272 262 * @param {string[]} editedCellIds 273 263 * @returns {Set<string>} 274 264 */ 275 - _collectDirty(editedCellIds) { 276 - const dirty = new Set(); 265 + _collectDirty(editedCellIds: string[]): Set<string> { 266 + const dirty = new Set<string>(); 277 267 const queue = [...editedCellIds]; 278 268 279 269 while (queue.length > 0) { ··· 301 291 * @param {Set<string>} dirty - Set of dirty cell IDs 302 292 * @returns {Set<string>} Set of cell IDs whose display value actually changed 303 293 */ 304 - _evaluateDirty(dirty) { 294 + _evaluateDirty(dirty: Set<string>): Set<string> { 305 295 this._cyclePaths = []; 306 - const changed = new Set(); 296 + const changed = new Set<string>(); 307 297 308 298 // Filter to only formula cells that need recalculation 309 - const formulaCells = new Set(); 299 + const formulaCells = new Set<string>(); 310 300 for (const cellId of dirty) { 311 301 const cell = this.store.get(cellId); 312 302 if (cell && cell.f) { ··· 320 310 // In-degree only counts edges from OTHER formula cells in the dirty set. 321 311 // Non-formula dirty cells (edited value cells) are already resolved — they 322 312 // act as sources in the topological sort without needing evaluation. 323 - const inDegree = new Map(); 324 - const subDeps = new Map(); // within the dirty subgraph: source -> Set<target> 313 + const inDegree = new Map<string, number>(); 314 + const subDeps = new Map<string, Set<string>>(); // within the dirty subgraph: source -> Set<target> 325 315 326 316 for (const cellId of formulaCells) { 327 317 let degree = 0; ··· 374 364 } 375 365 376 366 // Detect cycles: formula cells not in sorted order are in cycles 377 - const cycleCells = new Set(); 367 + const cycleCells = new Set<string>(); 378 368 for (const cellId of formulaCells) { 379 369 if (!sortedSet.has(cellId)) { 380 370 cycleCells.add(cellId); ··· 412 402 * @param {string} cellId 413 403 * @param {Set<string>} changed - Accumulator for cells whose values changed 414 404 */ 415 - _evaluateCell(cellId, changed) { 405 + _evaluateCell(cellId: string, changed: Set<string>): void { 416 406 const cell = this.store.get(cellId); 417 407 if (!cell || !cell.f) return; 418 408 ··· 423 413 const oldVal = cell.v; 424 414 425 415 // Build a getCellValue that reads from the store 426 - const getCellValue = (ref) => { 416 + const getCellValue = (ref: string) => { 427 417 const data = this.store.get(ref); 428 418 if (!data) return ''; 429 419 if (data.f) return data.v; // Already evaluated (topo order guarantees this) ··· 451 441 * Uses DFS from cycle cells to find actual cycle paths. 452 442 * @param {Set<string>} cycleCells 453 443 */ 454 - _buildCyclePaths(cycleCells) { 444 + _buildCyclePaths(cycleCells: Set<string>): void { 455 445 const visited = new Set(); 456 446 const paths = []; 457 447 ··· 479 469 * @param {Set<string>} visited 480 470 * @returns {string[] | null} 481 471 */ 482 - _dfsFindCycle(cellId, cycleCells, path, inStack, visited) { 472 + _dfsFindCycle(cellId: string, cycleCells: Set<string>, path: string[], inStack: Set<string>, visited: Set<string>): string[] | null { 483 473 if (inStack.has(cellId)) { 484 474 // Found a cycle — extract path from the first occurrence to here 485 475 const cycleStart = path.indexOf(cellId);
+9 -10
src/sheets/sort.js src/sheets/sort.ts
··· 4 4 * Pure-logic: no DOM dependencies. The UI integration is in main.js. 5 5 */ 6 6 7 + import type { SortKey } from './types.js'; 8 + 9 + interface SortRow { 10 + [key: string]: unknown; 11 + [key: number]: unknown; 12 + } 13 + 7 14 /** 8 15 * Compare two values for sorting. Numbers sort numerically, 9 16 * strings sort lexicographically, numbers sort before strings, 10 17 * empty strings sort before everything. 11 - * 12 - * @param {any} a 13 - * @param {any} b 14 - * @returns {number} - Negative if a < b, positive if a > b, 0 if equal 15 18 */ 16 - function compareValues(a, b) { 19 + function compareValues(a: unknown, b: unknown): number { 17 20 // Handle empty strings — sort first 18 21 const aEmpty = a === '' || a === null || a === undefined; 19 22 const bEmpty = b === '' || b === null || b === undefined; ··· 44 47 * stable in all modern engines since ES2019). 45 48 * 46 49 * Does NOT mutate the input array. 47 - * 48 - * @param {object[]} rows - Array of row objects ({ _row, [colNum]: value }) 49 - * @param {{ col: number, order: 'asc' | 'desc' }[]} sortKeys - Up to 3 sort levels 50 - * @returns {object[]} - New sorted array 51 50 */ 52 - export function multiColumnSort(rows, sortKeys) { 51 + export function multiColumnSort(rows: SortRow[], sortKeys: SortKey[]): SortRow[] { 53 52 if (!rows || rows.length === 0) return []; 54 53 if (!sortKeys || sortKeys.length === 0) return [...rows]; 55 54
+11 -9
src/sheets/status-bar.js src/sheets/status-bar.ts
··· 5 5 * Only counts numeric values for stats computation. 6 6 */ 7 7 8 + interface SelectionStats { 9 + sum: number; 10 + average: number; 11 + count: number; 12 + min: number; 13 + max: number; 14 + } 15 + 8 16 /** 9 17 * Convert a value to a number, returning NaN for non-numeric values. 10 18 * Booleans count as numbers (true=1, false=0). 11 19 */ 12 - function toNumeric(v) { 20 + function toNumeric(v: unknown): number { 13 21 if (v === '' || v === null || v === undefined) return NaN; 14 22 if (typeof v === 'boolean') return v ? 1 : 0; 15 23 if (typeof v === 'number') return v; ··· 20 28 /** 21 29 * Compute aggregate statistics for an array of cell values. 22 30 * Returns null if fewer than 2 values (single-cell selection). 23 - * 24 - * @param {Array} values - Array of cell display values 25 - * @returns {{ sum: number, average: number, count: number, min: number, max: number } | null} 26 31 */ 27 - export function computeSelectionStats(values) { 32 + export function computeSelectionStats(values: unknown[]): SelectionStats | null { 28 33 if (!values || values.length < 2) return null; 29 34 30 35 const nums = values.map(toNumeric).filter(n => !isNaN(n)); ··· 50 55 /** 51 56 * Format a stat value for display in the status bar. 52 57 * Integers show without decimals; floats show up to 2 decimal places. 53 - * 54 - * @param {number} value 55 - * @returns {string} 56 58 */ 57 - export function formatStatValue(value) { 59 + export function formatStatValue(value: number): string { 58 60 if (value === 0) return '0'; 59 61 // Round to 2 decimal places first 60 62 const rounded = Math.round(value * 100) / 100;
-16
src/sheets/tab-handler.js
··· 1 - /** 2 - * Tab key behavior resolver for the sheets editor. 3 - * 4 - * Pure function: given context, returns the action to take. 5 - */ 6 - 7 - /** 8 - * Determine what Tab/Shift+Tab should do in the spreadsheet. 9 - * 10 - * @param {object} ctx 11 - * @param {boolean} ctx.shiftKey - Shift key is held 12 - * @returns {'moveRight' | 'moveLeft'} 13 - */ 14 - export function resolveSheetTabAction({ shiftKey }) { 15 - return shiftKey ? 'moveLeft' : 'moveRight'; 16 - }
+14
src/sheets/tab-handler.ts
··· 1 + /** 2 + * Tab key behavior resolver for the sheets editor. 3 + * 4 + * Pure function: given context, returns the action to take. 5 + */ 6 + 7 + import type { TabAction } from './types.js'; 8 + 9 + /** 10 + * Determine what Tab/Shift+Tab should do in the spreadsheet. 11 + */ 12 + export function resolveSheetTabAction({ shiftKey }: { shiftKey: boolean }): TabAction { 13 + return shiftKey ? 'moveLeft' : 'moveRight'; 14 + }
+425
src/sheets/types.ts
··· 1 + /** 2 + * Shared type definitions for the Tools Sheets module. 3 + * 4 + * These types are used across multiple sheets source files. 5 + */ 6 + 7 + // --- Cell Reference Types --- 8 + 9 + export interface CellRef { 10 + col: number; 11 + row: number; 12 + } 13 + 14 + // --- Cell Style Types --- 15 + 16 + export interface BorderStyle { 17 + top?: string; 18 + bottom?: string; 19 + left?: string; 20 + right?: string; 21 + } 22 + 23 + export interface CellStyle { 24 + bold?: boolean; 25 + italic?: boolean; 26 + underline?: boolean; 27 + strikethrough?: boolean; 28 + color?: string; 29 + bg?: string; 30 + align?: 'left' | 'center' | 'right'; 31 + verticalAlign?: 'top' | 'middle' | 'bottom'; 32 + fontSize?: number; 33 + fontFamily?: 'sans-serif' | 'serif' | 'monospace'; 34 + format?: 'auto' | 'number' | 'currency' | 'percent' | 'date' | 'text'; 35 + borders?: BorderStyle; 36 + wrap?: boolean; 37 + } 38 + 39 + // --- Cell Data Types --- 40 + 41 + export type CellValue = string | number | boolean | Date | undefined; 42 + 43 + export interface CellData { 44 + v: CellValue | ''; 45 + f: string; 46 + s: CellStyle; 47 + } 48 + 49 + // --- Formula Types --- 50 + 51 + export type TokenType = 52 + | 'NUMBER' 53 + | 'STRING' 54 + | 'BOOLEAN' 55 + | 'CELL_REF' 56 + | 'CROSS_SHEET_REF' 57 + | 'RANGE' 58 + | 'FUNCTION' 59 + | 'IDENTIFIER' 60 + | 'OPERATOR' 61 + | 'LPAREN' 62 + | 'RPAREN' 63 + | 'COMMA' 64 + | 'COLON' 65 + | 'BANG' 66 + | 'EOF'; 67 + 68 + export interface CrossSheetRefValue { 69 + sheetName: string; 70 + ref: string; 71 + } 72 + 73 + export interface Token { 74 + type: TokenType; 75 + value?: string | number | boolean | CrossSheetRefValue; 76 + } 77 + 78 + export type FormulaResult = CellValue | CellValue[] | string; 79 + 80 + export type GetCellValue = (ref: string) => CellValue | ''; 81 + 82 + export interface CrossSheetResolver { 83 + sheetExists(sheetName: string): boolean; 84 + getSheetCellValue(sheetName: string, cellRef: string): CellValue | ''; 85 + } 86 + 87 + export type FormatType = 'auto' | 'number' | 'currency' | 'percent' | 'date' | 'text'; 88 + 89 + // --- Range Array with dimensions --- 90 + export interface RangeArray extends Array<CellValue | '' | undefined> { 91 + _rangeRows?: number; 92 + _rangeCols?: number; 93 + } 94 + 95 + // --- Named Ranges --- 96 + 97 + export interface NamedRangeEntry { 98 + name: string; 99 + range: string; 100 + sheet: string; 101 + } 102 + 103 + export type NamedRangesMap = Record<string, NamedRangeEntry>; 104 + 105 + // --- Recalc Engine Types --- 106 + 107 + export interface RecalcCellData { 108 + v: CellValue | ''; 109 + f: string; 110 + } 111 + 112 + export interface CellStore { 113 + get(id: string): RecalcCellData | null; 114 + set(id: string, cell: RecalcCellData): void; 115 + has(id: string): boolean; 116 + entries(): IterableIterator<[string, RecalcCellData]>; 117 + getAllFormulaCells(): Array<[string, RecalcCellData]>; 118 + } 119 + 120 + export interface RecalcOptions { 121 + onEvaluate?: (cellId: string) => void; 122 + namedRanges?: NamedRangesMap; 123 + crossSheetResolver?: CrossSheetResolver; 124 + } 125 + 126 + // --- Chart Types --- 127 + 128 + export type ChartType = 'bar' | 'line' | 'pie' | 'scatter'; 129 + 130 + export interface ChartConfig { 131 + type: ChartType; 132 + range: string; 133 + title?: string; 134 + xAxisLabel?: string; 135 + yAxisLabel?: string; 136 + } 137 + 138 + export interface ChartValidationResult { 139 + valid: boolean; 140 + errors: string[]; 141 + } 142 + 143 + export interface DataRange { 144 + startCol: number; 145 + startRow: number; 146 + endCol: number; 147 + endRow: number; 148 + } 149 + 150 + export interface ChartDataset { 151 + label: string; 152 + data: (CellValue | '' | { x: CellValue | ''; y: CellValue | '' })[]; 153 + backgroundColor?: string | string[]; 154 + borderColor?: string | string[]; 155 + borderWidth?: number; 156 + fill?: boolean; 157 + tension?: number; 158 + pointRadius?: number; 159 + } 160 + 161 + export interface TransformedChartData { 162 + labels: (CellValue | '' | number)[]; 163 + datasets: ChartDataset[]; 164 + } 165 + 166 + // --- Selection Types --- 167 + 168 + export interface SelectionRange { 169 + startCol: number; 170 + startRow: number; 171 + endCol: number; 172 + endRow: number; 173 + } 174 + 175 + // --- Conditional Formatting Types --- 176 + 177 + export type CfRuleType = 178 + | 'greaterThan' 179 + | 'lessThan' 180 + | 'equalTo' 181 + | 'between' 182 + | 'textContains' 183 + | 'isEmpty' 184 + | 'isNotEmpty'; 185 + 186 + export interface CfRule { 187 + type: CfRuleType; 188 + value?: string | number; 189 + value2?: string | number; 190 + bgColor?: string; 191 + textColor?: string; 192 + name?: string; 193 + } 194 + 195 + export interface CfStyleResult { 196 + bgColor?: string; 197 + textColor?: string; 198 + } 199 + 200 + // --- Data Validation Types --- 201 + 202 + export type ValidationRuleType = 'list' | 'numberBetween' | 'textLength'; 203 + 204 + export interface ValidationRule { 205 + type: ValidationRuleType; 206 + value?: string; 207 + value2?: string; 208 + items?: string[]; 209 + } 210 + 211 + export interface ValidationResult { 212 + valid: boolean; 213 + message?: string; 214 + } 215 + 216 + // --- Filter Types --- 217 + 218 + export interface FilterRow { 219 + _row: number; 220 + [col: number]: CellValue | ''; 221 + } 222 + 223 + export type FilterState = Record<number, Record<string, boolean>>; 224 + 225 + // --- Sort Types --- 226 + 227 + export type SortOrder = 'asc' | 'desc'; 228 + 229 + export interface SortKey { 230 + col: number | string; 231 + order: SortOrder; 232 + } 233 + 234 + // --- Formula Autocomplete Types --- 235 + 236 + export interface FormulaFunction { 237 + name: string; 238 + signature: string; 239 + } 240 + 241 + // --- Formula Highlighter Types --- 242 + 243 + export type HighlightTokenType = 244 + | 'operator' 245 + | 'whitespace' 246 + | 'error' 247 + | 'string' 248 + | 'number' 249 + | 'boolean' 250 + | 'cell_ref' 251 + | 'function' 252 + | 'paren' 253 + | 'identifier' 254 + | 'unknown'; 255 + 256 + export interface HighlightToken { 257 + text: string; 258 + type: HighlightTokenType; 259 + start: number; 260 + end: number; 261 + } 262 + 263 + // --- Formula Tooltip Types --- 264 + 265 + export interface FunctionParam { 266 + name: string; 267 + desc: string; 268 + required: boolean; 269 + } 270 + 271 + export interface FunctionMetadataEntry { 272 + desc: string; 273 + params: FunctionParam[]; 274 + } 275 + 276 + export interface DetectedFunction { 277 + functionName: string; 278 + paramIndex: number; 279 + } 280 + 281 + // --- Range Highlight Types --- 282 + 283 + export interface FormulaRange { 284 + ref: string; 285 + startIndex: number; 286 + endIndex: number; 287 + } 288 + 289 + export interface ColoredRange extends FormulaRange { 290 + color: string; 291 + } 292 + 293 + export interface HighlightBorders { 294 + top: boolean; 295 + bottom: boolean; 296 + left: boolean; 297 + right: boolean; 298 + } 299 + 300 + // --- Drag Fill Types --- 301 + 302 + export interface FillPattern { 303 + type: string; 304 + step?: number; 305 + stepMs?: number; 306 + } 307 + 308 + export type FillDirection = 'forward' | 'backward'; 309 + 310 + // --- Paste Special Types --- 311 + 312 + export interface PasteCellData { 313 + v: CellValue | '' | null; 314 + f: string; 315 + s: Record<string, unknown>; 316 + } 317 + 318 + // --- Virtual Scroll Types --- 319 + 320 + export interface VisibleRange { 321 + startRow: number; 322 + endRow: number; 323 + } 324 + 325 + export interface VirtualScrollParams { 326 + scrollTop: number; 327 + viewportHeight: number; 328 + totalRows: number; 329 + rowHeight?: number; 330 + bufferRows?: number; 331 + } 332 + 333 + // --- Named Range Store --- 334 + 335 + export interface NamedRangeStore { 336 + get(key: string): string | undefined; 337 + set(key: string, value: string): void; 338 + has(key: string): boolean; 339 + delete(key: string): void; 340 + forEach(callback: (value: string, key: string) => void): void; 341 + } 342 + 343 + // --- XLSX Import Types --- 344 + 345 + export interface XlsxCellStyle { 346 + bold?: boolean; 347 + italic?: boolean; 348 + underline?: boolean; 349 + strikethrough?: boolean; 350 + fontSize?: number; 351 + color?: string; 352 + bg?: string; 353 + align?: 'left' | 'center' | 'right'; 354 + verticalAlign?: 'top' | 'middle' | 'bottom'; 355 + wrap?: boolean; 356 + format?: string; 357 + } 358 + 359 + export interface XlsxCellData { 360 + v: string | number | ''; 361 + f: string; 362 + s: XlsxCellStyle; 363 + } 364 + 365 + export interface ParsedSheet { 366 + name: string; 367 + cells: Map<string, XlsxCellData>; 368 + rowCount: number; 369 + colCount: number; 370 + } 371 + 372 + export interface ImportXlsxOptions { 373 + ydoc: { transact(fn: () => void): void }; 374 + getActiveSheet: () => { 375 + get(key: string): unknown; 376 + set(key: string, value: unknown): void; 377 + }; 378 + setCellData: (id: string, data: Partial<XlsxCellData>) => void; 379 + getCells?: () => unknown; 380 + renderGrid: () => void; 381 + showToast: (message: string, duration?: number) => void; 382 + evalCache: Map<string, unknown>; 383 + DEFAULT_ROWS: number; 384 + DEFAULT_COLS: number; 385 + } 386 + 387 + // --- Tab Handler Types --- 388 + 389 + export type TabAction = 'moveRight' | 'moveLeft'; 390 + 391 + export interface TabContext { 392 + sheetKey: boolean; 393 + } 394 + 395 + // --- Notes Types --- 396 + 397 + export type NotesMap = Record<string, string>; 398 + 399 + // --- Merge Types --- 400 + 401 + export interface MergeData { 402 + startCol: number; 403 + startRow: number; 404 + endCol: number; 405 + endRow: number; 406 + } 407 + 408 + export interface MergeMapEntry { 409 + hidden: boolean; 410 + merge: MergeData; 411 + colspan?: number; 412 + rowspan?: number; 413 + } 414 + 415 + // --- Shortcut Types --- 416 + 417 + export interface ShortcutEntry { 418 + keys: string[]; 419 + label: string; 420 + } 421 + 422 + export interface ShortcutCategory { 423 + category: string; 424 + shortcuts: ShortcutEntry[]; 425 + }
+4 -14
src/sheets/virtual-scroll.js src/sheets/virtual-scroll.ts
··· 7 7 * rendering is handled by renderGrid() in main.js. 8 8 */ 9 9 10 + import type { VisibleRange, VirtualScrollParams } from './types.js'; 11 + 10 12 // --- Constants --- 11 13 export const DEFAULT_ROW_HEIGHT = 26; // px, matches body row height in main.js 12 14 export const DEFAULT_BUFFER_ROWS = 10; // extra rows above and below viewport 13 15 14 16 /** 15 17 * Calculate which rows should be rendered based on scroll position. 16 - * 17 - * @param {Object} params 18 - * @param {number} params.scrollTop - Current scroll position (px) 19 - * @param {number} params.viewportHeight - Height of visible area (px) 20 - * @param {number} params.totalRows - Total number of rows in the sheet 21 - * @param {number} [params.rowHeight=DEFAULT_ROW_HEIGHT] - Height of each row (px) 22 - * @param {number} [params.bufferRows=DEFAULT_BUFFER_ROWS] - Extra rows above/below 23 - * @returns {{ startRow: number, endRow: number }} 1-based row range to render 24 18 */ 25 19 export function calculateVisibleRange({ 26 20 scrollTop, ··· 28 22 totalRows, 29 23 rowHeight = DEFAULT_ROW_HEIGHT, 30 24 bufferRows = DEFAULT_BUFFER_ROWS, 31 - }) { 25 + }: VirtualScrollParams): VisibleRange { 32 26 // Calculate first visible row (0-based index) 33 27 const firstVisibleIndex = Math.floor(scrollTop / rowHeight); 34 28 ··· 49 43 /** 50 44 * Calculate the total height needed for the spacer element. 51 45 * This maintains correct scrollbar size when not all rows are rendered. 52 - * 53 - * @param {number} totalRows - Total number of rows in the sheet 54 - * @param {number} [rowHeight=DEFAULT_ROW_HEIGHT] - Height of each row (px) 55 - * @returns {number} Total height in pixels 56 46 */ 57 - export function calculateSpacerHeight(totalRows, rowHeight = DEFAULT_ROW_HEIGHT) { 47 + export function calculateSpacerHeight(totalRows: number, rowHeight: number = DEFAULT_ROW_HEIGHT): number { 58 48 return totalRows * rowHeight; 59 49 }
+52 -56
src/sheets/xlsx-import.js src/sheets/xlsx-import.ts
··· 5 5 * cell data format used by the Yjs-backed spreadsheet. 6 6 */ 7 7 8 - /** 9 - * @typedef {Object} CellData 10 - * @property {string|number} v - Cell value 11 - * @property {string} f - Formula (without leading =) 12 - * @property {Object} [s] - Style object 13 - * @property {boolean} [s.bold] - Bold formatting 14 - * @property {string} [s.format] - Number format string 15 - */ 16 - 17 - /** 18 - * @typedef {Object} ParsedSheet 19 - * @property {string} name - Sheet name 20 - * @property {Map<string, CellData>} cells - Map of cellId -> CellData 21 - * @property {number} rowCount - Number of rows with data 22 - * @property {number} colCount - Number of columns with data 23 - */ 8 + import type { XlsxCellData, XlsxCellStyle, ParsedSheet, ImportXlsxOptions } from './types.js'; 24 9 25 10 /** 26 11 * Convert a 1-based column number to a spreadsheet letter (A, B, ..., Z, AA, ...). 27 - * @param {number} col - 1-based column index 28 - * @returns {string} 29 12 */ 30 - function colToLetter(col) { 13 + function colToLetter(col: number): string { 31 14 let s = ''; 32 15 let n = col; 33 16 while (n > 0) { ··· 40 23 41 24 /** 42 25 * Create a cell ID from column and row (1-based). 43 - * @param {number} col 44 - * @param {number} row 45 - * @returns {string} e.g. "A1", "B2" 46 26 */ 47 - function cellId(col, row) { 27 + function cellId(col: number, row: number): string { 48 28 return `${colToLetter(col)}${row}`; 49 29 } 50 30 31 + // SheetJS type stubs for the dynamic import 32 + interface XlsxCell { 33 + v?: string | number; 34 + f?: string; 35 + z?: string; 36 + s?: { 37 + font?: { 38 + bold?: boolean; 39 + italic?: boolean; 40 + underline?: boolean; 41 + strike?: boolean; 42 + sz?: number; 43 + color?: { rgb?: string }; 44 + }; 45 + fill?: { 46 + fgColor?: { rgb?: string }; 47 + }; 48 + alignment?: { 49 + horizontal?: string; 50 + vertical?: string; 51 + wrapText?: boolean; 52 + }; 53 + }; 54 + } 55 + 56 + interface XlsxUtils { 57 + decode_range(range: string): { s: { r: number; c: number }; e: { r: number; c: number } }; 58 + encode_cell(cell: { r: number; c: number }): string; 59 + } 60 + 61 + interface XlsxLib { 62 + read(data: ArrayBuffer, opts: { type: string; cellStyles: boolean; cellFormula: boolean }): { 63 + SheetNames: string[]; 64 + Sheets: Record<string, Record<string, XlsxCell>>; 65 + }; 66 + utils: XlsxUtils; 67 + } 68 + 51 69 /** 52 70 * Parse an .xlsx ArrayBuffer using the provided XLSX library. 53 - * 54 - * @param {ArrayBuffer} arrayBuffer - The .xlsx file contents 55 - * @param {object} XLSX - The SheetJS library object 56 - * @returns {ParsedSheet} Parsed sheet data 57 71 */ 58 - export function parseXlsxWithLib(arrayBuffer, XLSX) { 72 + export function parseXlsxWithLib(arrayBuffer: ArrayBuffer, XLSX: XlsxLib): ParsedSheet { 59 73 const workbook = XLSX.read(arrayBuffer, { type: 'array', cellStyles: true, cellFormula: true }); 60 74 61 75 const sheetName = workbook.SheetNames[0]; ··· 64 78 } 65 79 66 80 const worksheet = workbook.Sheets[sheetName]; 67 - const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1'); 81 + const range = XLSX.utils.decode_range(worksheet['!ref'] as string || 'A1'); 68 82 69 - const cells = new Map(); 83 + const cells = new Map<string, XlsxCellData>(); 70 84 let maxRow = 0; 71 85 let maxCol = 0; 72 86 ··· 80 94 const col = c + 1; 81 95 const id = cellId(col, row); 82 96 83 - const data = { v: '', f: '', s: {} }; 97 + const data: XlsxCellData = { v: '', f: '', s: {} }; 84 98 85 99 // Extract value 86 100 if (cell.v !== undefined && cell.v !== null) { ··· 118 132 if (cell.s.alignment && cell.s.alignment.horizontal) { 119 133 const align = cell.s.alignment.horizontal; 120 134 if (['left', 'center', 'right'].includes(align)) { 121 - data.s.align = align; 135 + data.s.align = align as 'left' | 'center' | 'right'; 122 136 } 123 137 } 124 138 if (cell.s.alignment && cell.s.alignment.vertical) { ··· 154 168 155 169 /** 156 170 * Map Excel number format strings to our internal format identifiers. 157 - * 158 - * @param {string} excelFormat 159 - * @returns {string|undefined} 160 171 */ 161 - export function mapExcelFormat(excelFormat) { 172 + export function mapExcelFormat(excelFormat: string): string | undefined { 162 173 if (!excelFormat || excelFormat === 'General') return undefined; 163 174 164 175 // Currency patterns ··· 179 190 /** 180 191 * Validate that the given ArrayBuffer looks like a valid .xlsx file. 181 192 * An .xlsx is a ZIP file, so it starts with the PK signature (0x504B0304). 182 - * 183 - * @param {ArrayBuffer} arrayBuffer 184 - * @returns {boolean} 185 193 */ 186 - export function isValidXlsx(arrayBuffer) { 194 + export function isValidXlsx(arrayBuffer: ArrayBuffer): boolean { 187 195 if (!arrayBuffer || arrayBuffer.byteLength < 4) return false; 188 196 const view = new Uint8Array(arrayBuffer); 189 197 return view[0] === 0x50 && view[1] === 0x4B && view[2] === 0x03 && view[3] === 0x04; ··· 192 200 /** 193 201 * Import an .xlsx File into the spreadsheet via Yjs. 194 202 * DOM-coupled entry point — not unit-testable directly. 195 - * 196 - * @param {File} file - The .xlsx file 197 - * @param {object} opts 198 - * @param {object} opts.ydoc - Yjs document 199 - * @param {function} opts.getActiveSheet - Returns the active Yjs sheet map 200 - * @param {function} opts.setCellData - Function to set cell data by ID 201 - * @param {function} opts.getCells - Function to get cells Yjs map 202 - * @param {function} opts.renderGrid - Function to re-render the grid 203 - * @param {function} opts.showToast - Toast notification function 204 - * @param {object} opts.evalCache - Formula evaluation cache to clear 205 - * @param {number} opts.DEFAULT_ROWS - Default row count 206 - * @param {number} opts.DEFAULT_COLS - Default column count 207 203 */ 208 - export async function importXlsx(file, opts) { 204 + export async function importXlsx(file: File, opts: ImportXlsxOptions): Promise<void> { 209 205 const { ydoc, getActiveSheet, setCellData, renderGrid, showToast, evalCache, DEFAULT_ROWS, DEFAULT_COLS } = opts; 210 206 211 207 try { ··· 216 212 return; 217 213 } 218 214 219 - const XLSX = await import('xlsx'); 215 + const XLSX = await import('xlsx') as unknown as XlsxLib; 220 216 const parsed = parseXlsxWithLib(arrayBuffer, XLSX); 221 217 222 218 if (parsed.cells.size === 0) { ··· 231 227 setCellData(id, data); 232 228 } 233 229 234 - if (parsed.rowCount > (sheet.get('rowCount') || DEFAULT_ROWS)) { 230 + if (parsed.rowCount > ((sheet.get('rowCount') as number) || DEFAULT_ROWS)) { 235 231 sheet.set('rowCount', parsed.rowCount); 236 232 } 237 - if (parsed.colCount > (sheet.get('colCount') || DEFAULT_COLS)) { 233 + if (parsed.colCount > ((sheet.get('colCount') as number) || DEFAULT_COLS)) { 238 234 sheet.set('colCount', parsed.colCount); 239 235 } 240 236 });
tests/accessibility.test.js tests/accessibility.test.ts
tests/autoformat.test.js tests/autoformat.test.ts
tests/block-handle.test.js tests/block-handle.test.ts
tests/cell-borders.test.js tests/cell-borders.test.ts
tests/cell-formatting.test.js tests/cell-formatting.test.ts
tests/cell-notes.test.js tests/cell-notes.test.ts
tests/charts.test.js tests/charts.test.ts
tests/conditional-format.test.js tests/conditional-format.test.ts
tests/context-menu.test.js tests/context-menu.test.ts
tests/cross-sheet.test.js tests/cross-sheet.test.ts
+3 -3
tests/crypto.test.js tests/crypto.test.ts
··· 6 6 const key = await generateKey(); 7 7 expect(key).toBeDefined(); 8 8 expect(key.type).toBe('secret'); 9 - expect(key.algorithm.name).toBe('AES-GCM'); 10 - expect(key.algorithm.length).toBe(256); 9 + expect((key.algorithm as AesKeyAlgorithm).name).toBe('AES-GCM'); 10 + expect((key.algorithm as AesKeyAlgorithm).length).toBe(256); 11 11 }); 12 12 13 13 it('exports to base64url string', async () => { ··· 80 80 81 81 it('handles unicode', async () => { 82 82 const key = await generateKey(); 83 - const original = 'Emoji test: 🔐📝📊 and CJK: 你好世界'; 83 + const original = 'Emoji test: \uD83D\uDD10\uD83D\uDCDD\uD83D\uDCCA and CJK: \u4F60\u597D\u4E16\u754C'; 84 84 const encrypted = await encryptString(original, key); 85 85 const decrypted = await decryptString(encrypted, key); 86 86 expect(decrypted).toBe(original);
tests/csv-headers.test.js tests/csv-headers.test.ts
tests/dark-mode.test.js tests/dark-mode.test.ts
tests/data-validation.test.js tests/data-validation.test.ts
tests/docx-import.test.js tests/docx-import.test.ts
tests/drag-fill.test.js tests/drag-fill.test.ts
tests/extensions.test.js tests/extensions.test.ts
tests/filter.test.js tests/filter.test.ts
tests/find-replace.test.js tests/find-replace.test.ts
tests/format-painter.test.js tests/format-painter.test.ts
tests/formula-autocomplete.test.js tests/formula-autocomplete.test.ts
tests/formula-highlighter.test.js tests/formula-highlighter.test.ts
tests/formula-tooltip.test.js tests/formula-tooltip.test.ts
tests/formula-tracer.test.js tests/formula-tracer.test.ts
tests/formulas-extended.test.js tests/formulas-extended.test.ts
tests/formulas-power.test.js tests/formulas-power.test.ts
tests/formulas.test.js tests/formulas.test.ts
+30 -25
tests/integration.test.js tests/integration.test.ts
··· 8 8 * then exercises the full create-document -> encrypt -> store -> retrieve -> decrypt flow. 9 9 */ 10 10 11 - let baseUrl; 12 - let server; 11 + let baseUrl: string; 12 + let server: import('http').Server; 13 13 14 14 beforeAll(async () => { 15 15 const { createServer } = await import('http'); ··· 45 45 deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'), 46 46 }; 47 47 48 - app.post('/api/documents', (req, res) => { 48 + type Req = import('express').Request; 49 + type Res = import('express').Response; 50 + 51 + app.post('/api/documents', (req: Req, res: Res) => { 49 52 const id = randomUUID(); 50 - const { type, name_encrypted } = req.body; 53 + const { type, name_encrypted } = req.body as { type?: string; name_encrypted?: string }; 51 54 if (!type || !['doc', 'sheet'].includes(type)) { 52 - return res.status(400).json({ error: 'type must be doc or sheet' }); 55 + res.status(400).json({ error: 'type must be doc or sheet' }); 56 + return; 53 57 } 54 58 stmts.insert.run(id, type, name_encrypted || null); 55 59 res.json({ id }); 56 60 }); 57 61 58 - app.get('/api/documents', (_req, res) => res.json(stmts.getAll.all())); 62 + app.get('/api/documents', (_req: Req, res: Res) => res.json(stmts.getAll.all())); 59 63 60 - app.get('/api/documents/:id', (req, res) => { 61 - const doc = stmts.getOne.get(req.params.id); 62 - if (!doc) return res.status(404).json({ error: 'Not found' }); 64 + app.get('/api/documents/:id', (req: Req, res: Res) => { 65 + const doc = stmts.getOne.get(req.params['id']); 66 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 63 67 res.json(doc); 64 68 }); 65 69 66 - app.delete('/api/documents/:id', (req, res) => { 67 - stmts.deleteDoc.run(req.params.id); 70 + app.delete('/api/documents/:id', (req: Req, res: Res) => { 71 + stmts.deleteDoc.run(req.params['id']); 68 72 res.json({ ok: true }); 69 73 }); 70 74 71 - app.put('/api/documents/:id/name', (req, res) => { 72 - stmts.putName.run(req.body.name_encrypted, req.params.id); 75 + app.put('/api/documents/:id/name', (req: Req, res: Res) => { 76 + stmts.putName.run((req.body as { name_encrypted?: string }).name_encrypted, req.params['id']); 73 77 res.json({ ok: true }); 74 78 }); 75 79 76 - app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req, res) => { 77 - stmts.putSnapshot.run(req.body, req.params.id); 80 + app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req: Req, res: Res) => { 81 + stmts.putSnapshot.run(req.body, req.params['id']); 78 82 res.json({ ok: true }); 79 83 }); 80 84 81 - app.get('/api/documents/:id/snapshot', (req, res) => { 82 - const row = stmts.getSnapshot.get(req.params.id); 83 - if (!row || !row.snapshot) return res.status(404).json({ error: 'No snapshot' }); 85 + app.get('/api/documents/:id/snapshot', (req: Req, res: Res) => { 86 + const row = stmts.getSnapshot.get(req.params['id']) as { snapshot: Buffer | null } | undefined; 87 + if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 84 88 res.type('application/octet-stream').send(row.snapshot); 85 89 }); 86 90 87 91 server = createServer(app); 88 - await new Promise((resolve) => { 92 + await new Promise<void>((resolve) => { 89 93 server.listen(0, () => { 90 - baseUrl = `http://localhost:${server.address().port}`; 94 + const addr = server.address(); 95 + baseUrl = `http://localhost:${typeof addr === 'object' && addr ? addr.port : 0}`; 91 96 resolve(); 92 97 }); 93 98 }); ··· 108 113 headers: { 'Content-Type': 'application/json' }, 109 114 body: JSON.stringify({ type: 'doc', name_encrypted: 'encrypted_name_here' }), 110 115 }); 111 - const { id: docId } = await createRes.json(); 116 + const { id: docId } = await createRes.json() as { id: string }; 112 117 expect(docId).toBeDefined(); 113 118 114 119 // Step 3: Simulate Yjs state — a binary snapshot ··· 129 134 headers: { 'Content-Type': 'application/octet-stream' }, 130 135 body: encryptedSnapshot, 131 136 }); 132 - expect((await putRes.json()).ok).toBe(true); 137 + expect((await putRes.json() as { ok: boolean }).ok).toBe(true); 133 138 134 139 // Step 6: Retrieve the encrypted snapshot from server 135 140 const getRes = await fetch(`${baseUrl}/api/documents/${docId}/snapshot`); ··· 155 160 headers: { 'Content-Type': 'application/json' }, 156 161 body: JSON.stringify({ type: 'sheet' }), 157 162 }); 158 - const { id: docId } = await createRes.json(); 163 + const { id: docId } = await createRes.json() as { id: string }; 159 164 160 165 // First snapshot 161 166 const state1 = new Uint8Array([1, 2, 3, 4, 5]); ··· 253 258 headers: { 'Content-Type': 'application/json' }, 254 259 body: JSON.stringify({ type: 'doc' }), 255 260 }); 256 - const { id: docId } = await createRes.json(); 261 + const { id: docId } = await createRes.json() as { id: string }; 257 262 258 263 const originalState = new TextEncoder().encode('This is my important document content'); 259 264 const encrypted = await encrypt(originalState, originalKey); ··· 281 286 headers: { 'Content-Type': 'application/json' }, 282 287 body: JSON.stringify({ type: 'doc' }), 283 288 }); 284 - const { id: docId } = await createRes.json(); 289 + const { id: docId } = await createRes.json() as { id: string }; 285 290 286 291 // Simulate a large Yjs state (100KB) 287 292 const size = 100 * 1024;
tests/landing-dragdrop.test.js tests/landing-dragdrop.test.ts
tests/landing-folders.test.js tests/landing-folders.test.ts
tests/landing-search.test.js tests/landing-search.test.ts
tests/landing-sort.test.js tests/landing-sort.test.ts
tests/landing-stars.test.js tests/landing-stars.test.ts
tests/landing-trash.test.js tests/landing-trash.test.ts
tests/line-spacing.test.js tests/line-spacing.test.ts
tests/markdown-export.test.js tests/markdown-export.test.ts
tests/markdown-parser.test.js tests/markdown-parser.test.ts
tests/markdown-toggle.test.js tests/markdown-toggle.test.ts
tests/mobile.test.js tests/mobile.test.ts
tests/multi-sort.test.js tests/multi-sort.test.ts
tests/named-ranges.test.js tests/named-ranges.test.ts
+3 -3
tests/offline.test.js tests/offline.test.ts
··· 17 17 } from '../src/lib/offline.js'; 18 18 19 19 describe('OfflineManager', () => { 20 - let manager; 20 + let manager: OfflineManager; 21 21 22 22 beforeEach(() => { 23 23 manager = new OfflineManager(); ··· 95 95 }); 96 96 97 97 describe('OfflineQueue', () => { 98 - let queue; 98 + let queue: OfflineQueue; 99 99 100 100 beforeEach(() => { 101 101 queue = new OfflineQueue(); ··· 179 179 }); 180 180 181 181 describe('CacheStrategy', () => { 182 - let strategy; 182 + let strategy: CacheStrategy; 183 183 184 184 beforeEach(() => { 185 185 strategy = new CacheStrategy();
tests/outline.test.js tests/outline.test.ts
tests/page-break.test.js tests/page-break.test.ts
tests/paragraph-spacing.test.js tests/paragraph-spacing.test.ts
tests/paste-special.test.js tests/paste-special.test.ts
tests/pdf-export.test.js tests/pdf-export.test.ts
tests/print-layout.test.js tests/print-layout.test.ts
+106 -63
tests/provider-sync.test.js tests/provider-sync.test.ts
··· 12 12 13 13 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 14 14 import * as Y from 'yjs'; 15 + import type { EncryptedProvider as EncryptedProviderType } from '../src/lib/provider.js'; 15 16 16 17 // --------------------------------------------------------------------------- 17 18 // Mock WebSocket 18 19 // --------------------------------------------------------------------------- 19 - class MockWebSocket { 20 - static CONNECTING = 0; 21 - static OPEN = 1; 22 - static CLOSING = 2; 23 - static CLOSED = 3; 20 + interface MockWebSocketInstance { 21 + url: string; 22 + readyState: number; 23 + binaryType: string; 24 + onopen: ((ev: Record<string, unknown>) => void) | null; 25 + onmessage: ((ev: { data: string | ArrayBuffer }) => void) | null; 26 + onclose: ((ev: { code: number }) => void) | null; 27 + onerror: (() => void) | null; 28 + _sent: unknown[]; 29 + send: (data: unknown) => void; 30 + close: () => void; 31 + _simulateOpen: () => void; 32 + _simulateMessage: (data: string | ArrayBuffer) => void; 33 + _simulateClose: () => void; 34 + } 35 + 36 + interface MockWebSocketConstructor { 37 + new (url: string): MockWebSocketInstance; 38 + CONNECTING: number; 39 + OPEN: number; 40 + CLOSING: number; 41 + CLOSED: number; 42 + _instances: MockWebSocketInstance[]; 43 + _reset: () => void; 44 + latest: MockWebSocketInstance | undefined; 45 + } 24 46 25 - constructor(url) { 26 - this.url = url; 27 - this.readyState = MockWebSocket.CONNECTING; 28 - this.binaryType = 'text'; 29 - this.onopen = null; 30 - this.onmessage = null; 31 - this.onclose = null; 32 - this.onerror = null; 33 - this._sent = []; 34 - // Auto-open after microtask to simulate real WS 35 - MockWebSocket._instances.push(this); 36 - } 47 + const MockWebSocket = function(this: MockWebSocketInstance, url: string) { 48 + this.url = url; 49 + this.readyState = MockWebSocket.CONNECTING; 50 + this.binaryType = 'text'; 51 + this.onopen = null; 52 + this.onmessage = null; 53 + this.onclose = null; 54 + this.onerror = null; 55 + this._sent = []; 56 + MockWebSocket._instances.push(this); 57 + } as unknown as MockWebSocketConstructor; 37 58 38 - send(data) { 39 - this._sent.push(data); 40 - } 59 + MockWebSocket.CONNECTING = 0; 60 + MockWebSocket.OPEN = 1; 61 + MockWebSocket.CLOSING = 2; 62 + MockWebSocket.CLOSED = 3; 63 + MockWebSocket._instances = [] as MockWebSocketInstance[]; 64 + MockWebSocket._reset = function() { 65 + MockWebSocket._instances = []; 66 + }; 67 + Object.defineProperty(MockWebSocket, 'latest', { 68 + get() { 69 + return MockWebSocket._instances[MockWebSocket._instances.length - 1]; 70 + }, 71 + }); 41 72 42 - close() { 43 - this.readyState = MockWebSocket.CLOSED; 44 - if (this.onclose) this.onclose({ code: 1000 }); 45 - } 73 + MockWebSocket.prototype.send = function(this: MockWebSocketInstance, data: unknown) { 74 + this._sent.push(data); 75 + }; 46 76 47 - // Test helpers 48 - _simulateOpen() { 49 - this.readyState = MockWebSocket.OPEN; 50 - if (this.onopen) this.onopen({}); 51 - } 77 + MockWebSocket.prototype.close = function(this: MockWebSocketInstance) { 78 + this.readyState = MockWebSocket.CLOSED; 79 + if (this.onclose) this.onclose({ code: 1000 }); 80 + }; 52 81 53 - _simulateMessage(data) { 54 - if (this.onmessage) this.onmessage({ data }); 55 - } 82 + MockWebSocket.prototype._simulateOpen = function(this: MockWebSocketInstance) { 83 + this.readyState = MockWebSocket.OPEN; 84 + if (this.onopen) this.onopen({}); 85 + }; 56 86 57 - _simulateClose() { 58 - this.readyState = MockWebSocket.CLOSED; 59 - if (this.onclose) this.onclose({ code: 1000 }); 60 - } 87 + MockWebSocket.prototype._simulateMessage = function(this: MockWebSocketInstance, data: string | ArrayBuffer) { 88 + if (this.onmessage) this.onmessage({ data }); 89 + }; 61 90 62 - static _instances = []; 63 - static _reset() { 64 - MockWebSocket._instances = []; 65 - } 66 - static get latest() { 67 - return MockWebSocket._instances[MockWebSocket._instances.length - 1]; 68 - } 69 - } 91 + MockWebSocket.prototype._simulateClose = function(this: MockWebSocketInstance) { 92 + this.readyState = MockWebSocket.CLOSED; 93 + if (this.onclose) this.onclose({ code: 1000 }); 94 + }; 70 95 71 96 // --------------------------------------------------------------------------- 72 97 // Mock fetch & crypto for provider 73 98 // --------------------------------------------------------------------------- 74 - let fetchCalls = []; 75 - let fetchResponses = {}; 99 + interface FetchCall { 100 + url: string; 101 + opts: RequestInit | undefined; 102 + } 76 103 77 - function mockFetch(url, opts) { 104 + interface MockResponse { 105 + ok: boolean; 106 + status?: number; 107 + json?: () => Promise<unknown>; 108 + arrayBuffer?: () => Promise<ArrayBuffer>; 109 + } 110 + 111 + let fetchCalls: FetchCall[] = []; 112 + let fetchResponses: Record<string, MockResponse> = {}; 113 + 114 + function mockFetch(url: string, opts?: RequestInit): Promise<MockResponse> { 78 115 fetchCalls.push({ url, opts }); 79 116 const key = opts?.method === 'PUT' ? `PUT:${url}` : `GET:${url}`; 80 117 const resp = fetchResponses[key] || fetchResponses[url]; ··· 89 126 // --------------------------------------------------------------------------- 90 127 // Dynamic import with mocks 91 128 // --------------------------------------------------------------------------- 92 - let EncryptedProvider; 129 + let EncryptedProvider: typeof EncryptedProviderType; 93 130 94 131 beforeEach(async () => { 95 132 MockWebSocket._reset(); ··· 97 134 fetchResponses = {}; 98 135 99 136 // Set up globals that the provider expects 100 - globalThis.WebSocket = MockWebSocket; 101 - globalThis.fetch = mockFetch; 102 - globalThis.location = { protocol: 'https:', host: 'localhost:3000' }; 103 - globalThis.document = { hidden: false, addEventListener: vi.fn() }; 104 - globalThis.window = { 137 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; 138 + globalThis.fetch = mockFetch as unknown as typeof fetch; 139 + (globalThis as Record<string, unknown>).location = { protocol: 'https:', host: 'localhost:3000' }; 140 + (globalThis as Record<string, unknown>).document = { hidden: false, addEventListener: vi.fn() }; 141 + (globalThis as Record<string, unknown>).window = { 105 142 addEventListener: vi.fn(), 106 143 removeEventListener: vi.fn(), 107 144 __importInProgress: false, ··· 109 146 110 147 // Mock the crypto module 111 148 vi.doMock('../src/lib/crypto.js', () => ({ 112 - encrypt: vi.fn(async (data) => data), // passthrough 113 - decrypt: vi.fn(async (data) => data), // passthrough 149 + encrypt: vi.fn(async (data: Uint8Array) => data), // passthrough 150 + decrypt: vi.fn(async (data: Uint8Array) => data), // passthrough 114 151 })); 115 152 116 153 // Fresh import each test ··· 126 163 // --------------------------------------------------------------------------- 127 164 // Helper: create a provider without auto-connecting (for fine-grained control) 128 165 // --------------------------------------------------------------------------- 129 - async function createProvider(opts = {}) { 166 + async function createProvider(opts: Record<string, unknown> = {}): Promise<{ doc: Y.Doc; provider: EncryptedProviderType; ws: MockWebSocketInstance }> { 130 167 const doc = new Y.Doc(); 131 - const cryptoKey = {}; // Dummy — encrypt/decrypt are mocked as passthrough 168 + const cryptoKey = {} as CryptoKey; // Dummy — encrypt/decrypt are mocked as passthrough 132 169 const provider = new EncryptedProvider(doc, 'test-room', cryptoKey, { 133 170 wsUrl: 'wss://localhost:3000/ws', 134 171 apiUrl: '', ··· 138 175 await vi.waitFor(() => { 139 176 expect(MockWebSocket.latest).toBeDefined(); 140 177 }, { timeout: 1000 }); 141 - return { doc, provider, ws: MockWebSocket.latest }; 178 + return { doc, provider, ws: MockWebSocket.latest! }; 142 179 } 143 180 144 181 // =========================================================================== ··· 246 283 const doc = new Y.Doc(); 247 284 const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 248 285 249 - const cryptoKey = {}; 250 - const provider = new EncryptedProvider(doc, 'test-room', cryptoKey, { 286 + const cryptoKey = {} as CryptoKey; 287 + new EncryptedProvider(doc, 'test-room', cryptoKey, { 251 288 wsUrl: 'wss://localhost:3000/ws', 252 289 apiUrl: '', 253 290 }); ··· 308 345 expect(provider.synced).toBe(true); 309 346 310 347 // Set import-in-progress 311 - globalThis.window.__importInProgress = true; 348 + (globalThis as Record<string, unknown>).window = { 349 + ...(globalThis as Record<string, Record<string, unknown>>).window, 350 + __importInProgress: true, 351 + }; 312 352 313 353 fetchCalls = []; 314 354 await provider._saveSnapshot(); ··· 317 357 expect(putCalls).toHaveLength(0); 318 358 319 359 // Clear it and verify saves work again 320 - globalThis.window.__importInProgress = false; 360 + (globalThis as Record<string, unknown>).window = { 361 + ...(globalThis as Record<string, Record<string, unknown>>).window, 362 + __importInProgress: false, 363 + }; 321 364 await provider._saveSnapshot(); 322 365 323 366 const putCalls2 = fetchCalls.filter(c => c.opts?.method === 'PUT'); ··· 388 431 expect(provider.synced).toBe(false); 389 432 390 433 fetchCalls = []; 391 - provider._onBeforeUnload(); 434 + provider._handleBeforeUnload(); 392 435 // Give async save a tick 393 436 await new Promise(r => setTimeout(r, 10)); 394 437
tests/range-highlight.test.js tests/range-highlight.test.ts
tests/recalc.test.js tests/recalc.test.ts
tests/search-state-extended.test.js tests/search-state-extended.test.ts
+51 -46
tests/server.test.js tests/server.test.ts
··· 1 1 import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 2 2 3 3 // Test the server API by starting it on a random port 4 - let baseUrl; 5 - let server; 4 + let baseUrl: string; 5 + let server: import('http').Server; 6 6 7 7 beforeAll(async () => { 8 8 // Dynamically import and start the server ··· 39 39 deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'), 40 40 }; 41 41 42 - app.get('/health', (_req, res) => res.json({ status: 'ok' })); 42 + type Req = import('express').Request; 43 + type Res = import('express').Response; 43 44 44 - app.post('/api/documents', (req, res) => { 45 + app.get('/health', (_req: Req, res: Res) => res.json({ status: 'ok' })); 46 + 47 + app.post('/api/documents', (req: Req, res: Res) => { 45 48 const id = randomUUID(); 46 - const { type, name_encrypted } = req.body; 49 + const { type, name_encrypted } = req.body as { type?: string; name_encrypted?: string }; 47 50 if (!type || !['doc', 'sheet'].includes(type)) { 48 - return res.status(400).json({ error: 'type must be doc or sheet' }); 51 + res.status(400).json({ error: 'type must be doc or sheet' }); 52 + return; 49 53 } 50 54 stmts.insert.run(id, type, name_encrypted || null); 51 55 res.json({ id }); 52 56 }); 53 57 54 - app.get('/api/documents', (_req, res) => res.json(stmts.getAll.all())); 58 + app.get('/api/documents', (_req: Req, res: Res) => res.json(stmts.getAll.all())); 55 59 56 - app.get('/api/documents/:id', (req, res) => { 57 - const doc = stmts.getOne.get(req.params.id); 58 - if (!doc) return res.status(404).json({ error: 'Not found' }); 60 + app.get('/api/documents/:id', (req: Req, res: Res) => { 61 + const doc = stmts.getOne.get(req.params['id']); 62 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 59 63 res.json(doc); 60 64 }); 61 65 62 - app.delete('/api/documents/:id', (req, res) => { 63 - stmts.deleteDoc.run(req.params.id); 66 + app.delete('/api/documents/:id', (req: Req, res: Res) => { 67 + stmts.deleteDoc.run(req.params['id']); 64 68 res.json({ ok: true }); 65 69 }); 66 70 67 - app.put('/api/documents/:id/name', (req, res) => { 68 - stmts.putName.run(req.body.name_encrypted, req.params.id); 71 + app.put('/api/documents/:id/name', (req: Req, res: Res) => { 72 + stmts.putName.run((req.body as { name_encrypted?: string }).name_encrypted, req.params['id']); 69 73 res.json({ ok: true }); 70 74 }); 71 75 72 - app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req, res) => { 73 - stmts.putSnapshot.run(req.body, req.params.id); 76 + app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req: Req, res: Res) => { 77 + stmts.putSnapshot.run(req.body, req.params['id']); 74 78 res.json({ ok: true }); 75 79 }); 76 80 77 - app.get('/api/documents/:id/snapshot', (req, res) => { 78 - const row = stmts.getSnapshot.get(req.params.id); 79 - if (!row || !row.snapshot) return res.status(404).json({ error: 'No snapshot' }); 81 + app.get('/api/documents/:id/snapshot', (req: Req, res: Res) => { 82 + const row = stmts.getSnapshot.get(req.params['id']) as { snapshot: Buffer | null } | undefined; 83 + if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 80 84 res.type('application/octet-stream').send(row.snapshot); 81 85 }); 82 86 83 87 server = createServer(app); 84 - await new Promise((resolve) => { 88 + await new Promise<void>((resolve) => { 85 89 server.listen(0, () => { 86 - baseUrl = `http://localhost:${server.address().port}`; 90 + const addr = server.address(); 91 + baseUrl = `http://localhost:${typeof addr === 'object' && addr ? addr.port : 0}`; 87 92 resolve(); 88 93 }); 89 94 }); ··· 96 101 describe('health', () => { 97 102 it('returns ok', async () => { 98 103 const res = await fetch(`${baseUrl}/health`); 99 - const data = await res.json(); 104 + const data = await res.json() as Record<string, unknown>; 100 105 expect(data.status).toBe('ok'); 101 106 }); 102 107 }); 103 108 104 109 describe('document CRUD', () => { 105 - let docId; 110 + let docId: string; 106 111 107 112 it('creates a document', async () => { 108 113 const res = await fetch(`${baseUrl}/api/documents`, { ··· 110 115 headers: { 'Content-Type': 'application/json' }, 111 116 body: JSON.stringify({ type: 'doc', name_encrypted: 'dGVzdA==' }), 112 117 }); 113 - const data = await res.json(); 114 - expect(data.id).toBeDefined(); 115 - docId = data.id; 118 + const data = await res.json() as Record<string, unknown>; 119 + expect(data['id']).toBeDefined(); 120 + docId = data['id'] as string; 116 121 }); 117 122 118 123 it('rejects invalid type', async () => { ··· 126 131 127 132 it('lists documents', async () => { 128 133 const res = await fetch(`${baseUrl}/api/documents`); 129 - const docs = await res.json(); 134 + const docs = await res.json() as Array<Record<string, unknown>>; 130 135 expect(docs.length).toBeGreaterThanOrEqual(1); 131 136 expect(docs.find(d => d.id === docId)).toBeDefined(); 132 137 }); 133 138 134 139 it('gets a single document', async () => { 135 140 const res = await fetch(`${baseUrl}/api/documents/${docId}`); 136 - const doc = await res.json(); 141 + const doc = await res.json() as Record<string, unknown>; 137 142 expect(doc.id).toBe(docId); 138 143 expect(doc.type).toBe('doc'); 139 144 expect(doc.name_encrypted).toBe('dGVzdA=='); ··· 151 156 body: JSON.stringify({ name_encrypted: 'bmV3bmFtZQ==' }), 152 157 }); 153 158 const res = await fetch(`${baseUrl}/api/documents/${docId}`); 154 - const doc = await res.json(); 159 + const doc = await res.json() as Record<string, unknown>; 155 160 expect(doc.name_encrypted).toBe('bmV3bmFtZQ=='); 156 161 }); 157 162 ··· 175 180 headers: { 'Content-Type': 'application/json' }, 176 181 body: JSON.stringify({ type: 'sheet' }), 177 182 }); 178 - const { id } = await createRes.json(); 183 + const { id } = await createRes.json() as { id: string }; 179 184 const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`); 180 185 expect(res.status).toBe(404); 181 186 }); ··· 194 199 headers: { 'Content-Type': 'application/json' }, 195 200 body: JSON.stringify({ type: 'sheet' }), 196 201 }); 197 - const data = await res.json(); 202 + const data = await res.json() as Record<string, unknown>; 198 203 expect(data.id).toBeDefined(); 199 204 200 - const docRes = await fetch(`${baseUrl}/api/documents/${data.id}`); 205 + const docRes = await fetch(`${baseUrl}/api/documents/${data['id']}`); 201 206 const doc = await docRes.json(); 202 207 expect(doc.type).toBe('sheet'); 203 208 }); ··· 213 218 headers: { 'Content-Type': 'application/json' }, 214 219 body: JSON.stringify({ type: i % 2 === 0 ? 'doc' : 'sheet', name_encrypted: `doc_${i}` }), 215 220 }); 216 - const data = await res.json(); 221 + const data = await res.json() as Record<string, unknown>; 217 222 ids.push(data.id); 218 223 } 219 224 ··· 239 244 headers: { 'Content-Type': 'application/json' }, 240 245 body: JSON.stringify({ type: 'doc', name_encrypted: 'first' }), 241 246 }); 242 - const doc1 = await res1.json(); 247 + const doc1 = await res1.json() as Record<string, unknown>; 243 248 244 249 const res2 = await fetch(`${baseUrl}/api/documents`, { 245 250 method: 'POST', 246 251 headers: { 'Content-Type': 'application/json' }, 247 252 body: JSON.stringify({ type: 'doc', name_encrypted: 'second' }), 248 253 }); 249 - const doc2 = await res2.json(); 254 + const doc2 = await res2.json() as Record<string, unknown>; 250 255 251 256 // Now update doc1's name — this should bump its updated_at 252 - await fetch(`${baseUrl}/api/documents/${doc1.id}/name`, { 257 + await fetch(`${baseUrl}/api/documents/${doc1['id']}/name`, { 253 258 method: 'PUT', 254 259 headers: { 'Content-Type': 'application/json' }, 255 260 body: JSON.stringify({ name_encrypted: 'updated_first' }), 256 261 }); 257 262 258 263 const listRes = await fetch(`${baseUrl}/api/documents`); 259 - const docs = await listRes.json(); 260 - const idx1 = docs.findIndex(d => d.id === doc1.id); 261 - const idx2 = docs.findIndex(d => d.id === doc2.id); 264 + const docs = await listRes.json() as Array<Record<string, unknown>>; 265 + const idx1 = docs.findIndex(d => d['id'] === doc1['id']); 266 + const idx2 = docs.findIndex(d => d['id'] === doc2['id']); 262 267 // doc1 was updated more recently, so should appear before doc2 263 268 expect(idx1).toBeLessThan(idx2); 264 269 }); ··· 272 277 headers: { 'Content-Type': 'application/json' }, 273 278 body: JSON.stringify({ type: 'doc' }), 274 279 }); 275 - const { id } = await createRes.json(); 280 + const { id } = await createRes.json() as { id: string }; 276 281 277 282 // Create a 150KB snapshot with a recognizable pattern 278 283 const size = 150 * 1024; ··· 306 311 headers: { 'Content-Type': 'application/json' }, 307 312 body: JSON.stringify({ type: 'doc' }), 308 313 }); 309 - const { id } = await createRes.json(); 314 + const { id } = await createRes.json() as { id: string }; 310 315 311 316 // Store first snapshot 312 317 const snap1 = new Uint8Array([1, 2, 3, 4, 5]); ··· 338 343 method: 'POST', 339 344 headers: { 'Content-Type': 'application/json' }, 340 345 body: JSON.stringify({ type: i % 2 === 0 ? 'doc' : 'sheet', name_encrypted: `concurrent_${i}` }), 341 - }).then(r => r.json()) 346 + }).then(r => r.json() as Promise<Record<string, unknown>>) 342 347 ); 343 348 344 349 const results = await Promise.all(promises); 345 - const ids = results.map(r => r.id); 350 + const ids = results.map(r => r['id'] as string); 346 351 347 352 // All should have unique IDs 348 353 expect(new Set(ids).size).toBe(20); 349 354 350 355 // All should be retrievable 351 356 const checks = await Promise.all( 352 - ids.map(id => fetch(`${baseUrl}/api/documents/${id}`).then(r => r.json())) 357 + ids.map(id => fetch(`${baseUrl}/api/documents/${id}`).then(r => r.json() as Promise<Record<string, unknown>>)) 353 358 ); 354 359 for (const doc of checks) { 355 360 expect(doc.id).toBeDefined(); ··· 365 370 method: 'POST', 366 371 headers: { 'Content-Type': 'application/json' }, 367 372 body: JSON.stringify({ type: 'doc' }), 368 - }).then(r => r.json()) 373 + }).then(r => r.json() as Promise<Record<string, unknown>>) 369 374 ) 370 375 ); 371 - const ids = creates.map(c => c.id); 376 + const ids = creates.map(c => c['id'] as string); 372 377 373 378 // Write unique snapshots to all 5 simultaneously 374 379 await Promise.all(
+69 -56
tests/sharing.test.js tests/sharing.test.ts
··· 13 13 */ 14 14 15 15 // --- Server API tests --- 16 - let baseUrl; 17 - let server; 16 + let baseUrl: string; 17 + let server: import('http').Server; 18 18 19 19 beforeAll(async () => { 20 20 const { createServer } = await import('http'); ··· 53 53 app.use(compression()); 54 54 app.use(express.json({ limit: '1mb' })); 55 55 56 - app.post('/api/documents', (req, res) => { 56 + interface DocRow { id: string; type: string; name_encrypted: string | null; share_mode: string | null; expires_at: string | null; snapshot: Buffer | null; created_at: string; updated_at: string; } 57 + interface SnapshotRow { snapshot: Buffer | null; expires_at: string | null; } 58 + 59 + app.post('/api/documents', (req: import('express').Request, res: import('express').Response) => { 57 60 const id = randomUUID(); 58 - const { type, name_encrypted } = req.body; 61 + const { type, name_encrypted } = req.body as { type?: string; name_encrypted?: string }; 59 62 if (!type || !['doc', 'sheet'].includes(type)) { 60 - return res.status(400).json({ error: 'type must be doc or sheet' }); 63 + res.status(400).json({ error: 'type must be doc or sheet' }); 64 + return; 61 65 } 62 66 stmts.insert.run(id, type, name_encrypted || null); 63 67 res.json({ id }); 64 68 }); 65 69 66 - app.get('/api/documents', (_req, res) => res.json(stmts.getAll.all())); 70 + app.get('/api/documents', (_req: import('express').Request, res: import('express').Response) => res.json(stmts.getAll.all())); 67 71 68 - app.get('/api/documents/:id', (req, res) => { 69 - const doc = stmts.getOne.get(req.params.id); 70 - if (!doc) return res.status(404).json({ error: 'Not found' }); 72 + app.get('/api/documents/:id', (req: import('express').Request, res: import('express').Response) => { 73 + const doc = stmts.getOne.get(req.params['id']) as DocRow | undefined; 74 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 71 75 res.json(doc); 72 76 }); 73 77 74 - app.delete('/api/documents/:id', (req, res) => { 75 - stmts.deleteDoc.run(req.params.id); 78 + app.delete('/api/documents/:id', (req: import('express').Request, res: import('express').Response) => { 79 + stmts.deleteDoc.run(req.params['id']); 76 80 res.json({ ok: true }); 77 81 }); 78 82 79 - app.put('/api/documents/:id/name', (req, res) => { 80 - stmts.putName.run(req.body.name_encrypted, req.params.id); 83 + app.put('/api/documents/:id/name', (req: import('express').Request, res: import('express').Response) => { 84 + stmts.putName.run((req.body as { name_encrypted?: string }).name_encrypted, req.params['id']); 81 85 res.json({ ok: true }); 82 86 }); 83 87 84 - app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req, res) => { 85 - const row = stmts.getOne.get(req.params.id); 86 - if (!row) return res.status(404).json({ error: 'Not found' }); 88 + app.put('/api/documents/:id/snapshot', express.raw({ limit: '50mb', type: '*/*' }), (req: import('express').Request, res: import('express').Response) => { 89 + const row = stmts.getOne.get(req.params['id']) as DocRow | undefined; 90 + if (!row) { res.status(404).json({ error: 'Not found' }); return; } 87 91 88 92 // Check expiry 89 93 if (row.expires_at) { 90 94 const expiresAt = new Date(row.expires_at); 91 95 if (expiresAt <= new Date()) { 92 - return res.status(410).json({ error: 'Document link has expired' }); 96 + res.status(410).json({ error: 'Document link has expired' }); 97 + return; 93 98 } 94 99 } 95 100 96 - stmts.putSnapshot.run(req.body, req.params.id); 101 + stmts.putSnapshot.run(req.body, req.params['id']); 97 102 res.json({ ok: true }); 98 103 }); 99 104 100 - app.get('/api/documents/:id/snapshot', (req, res) => { 101 - const row = stmts.getSnapshot.get(req.params.id); 102 - if (!row || !row.snapshot) return res.status(404).json({ error: 'No snapshot' }); 105 + app.get('/api/documents/:id/snapshot', (req: import('express').Request, res: import('express').Response) => { 106 + const row = stmts.getSnapshot.get(req.params['id']) as SnapshotRow | undefined; 107 + if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 103 108 104 109 // Check expiry 105 110 if (row.expires_at) { 106 111 const expiresAt = new Date(row.expires_at); 107 112 if (expiresAt <= new Date()) { 108 - return res.status(410).json({ error: 'Document link has expired' }); 113 + res.status(410).json({ error: 'Document link has expired' }); 114 + return; 109 115 } 110 116 } 111 117 ··· 113 119 }); 114 120 115 121 // Share settings endpoint 116 - app.put('/api/documents/:id/share', (req, res) => { 117 - const doc = stmts.getOne.get(req.params.id); 118 - if (!doc) return res.status(404).json({ error: 'Not found' }); 122 + app.put('/api/documents/:id/share', (req: import('express').Request, res: import('express').Response) => { 123 + const doc = stmts.getOne.get(req.params['id']) as DocRow | undefined; 124 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 119 125 120 - const { share_mode, expires_at } = req.body; 126 + const { share_mode, expires_at } = req.body as { share_mode?: string; expires_at?: string | null }; 121 127 122 128 // Validate share_mode 123 129 if (share_mode && !['edit', 'view'].includes(share_mode)) { 124 - return res.status(400).json({ error: 'share_mode must be "edit" or "view"' }); 130 + res.status(400).json({ error: 'share_mode must be "edit" or "view"' }); 131 + return; 125 132 } 126 133 127 134 // Validate expires_at if provided 128 135 if (expires_at !== null && expires_at !== undefined && expires_at !== '') { 129 136 const d = new Date(expires_at); 130 137 if (isNaN(d.getTime())) { 131 - return res.status(400).json({ error: 'Invalid expires_at date' }); 138 + res.status(400).json({ error: 'Invalid expires_at date' }); 139 + return; 132 140 } 133 141 } 134 142 135 143 stmts.updateShare.run( 136 144 share_mode || doc.share_mode, 137 145 expires_at === null ? null : (expires_at || doc.expires_at), 138 - req.params.id 146 + req.params['id'] 139 147 ); 140 148 141 - const updated = stmts.getOne.get(req.params.id); 149 + const updated = stmts.getOne.get(req.params['id']) as DocRow | undefined; 142 150 res.json({ 143 - share_mode: updated.share_mode, 144 - expires_at: updated.expires_at, 151 + share_mode: updated?.share_mode, 152 + expires_at: updated?.expires_at, 145 153 }); 146 154 }); 147 155 148 156 server = createServer(app); 149 - await new Promise((resolve) => { 157 + await new Promise<void>((resolve) => { 150 158 server.listen(0, () => { 151 - baseUrl = `http://localhost:${server.address().port}`; 159 + const addr = server.address(); 160 + baseUrl = `http://localhost:${typeof addr === 'object' && addr ? addr.port : 0}`; 152 161 resolve(); 153 162 }); 154 163 }); ··· 166 175 headers: { 'Content-Type': 'application/json' }, 167 176 body: JSON.stringify({ type: 'doc' }), 168 177 }); 169 - const { id } = await res.json(); 178 + const { id } = await res.json() as { id: string }; 170 179 171 180 const docRes = await fetch(`${baseUrl}/api/documents/${id}`); 172 - const doc = await docRes.json(); 181 + const doc = await docRes.json() as Record<string, unknown>; 173 182 expect(doc.share_mode).toBe('edit'); 174 183 }); 175 184 ··· 179 188 headers: { 'Content-Type': 'application/json' }, 180 189 body: JSON.stringify({ type: 'doc' }), 181 190 }); 182 - const { id } = await res.json(); 191 + const { id } = await res.json() as { id: string }; 183 192 184 193 const docRes = await fetch(`${baseUrl}/api/documents/${id}`); 185 - const doc = await docRes.json(); 194 + const doc = await docRes.json() as Record<string, unknown>; 186 195 expect(doc.expires_at).toBeNull(); 187 196 }); 188 197 189 198 it('share_mode and expires_at appear in document list', async () => { 190 199 const listRes = await fetch(`${baseUrl}/api/documents`); 191 - const docs = await listRes.json(); 200 + const docs = await listRes.json() as Record<string, unknown>[]; 192 201 expect(docs.length).toBeGreaterThan(0); 193 202 for (const doc of docs) { 194 203 expect(doc).toHaveProperty('share_mode'); ··· 198 207 }); 199 208 200 209 describe('PUT /api/documents/:id/share', () => { 201 - let docId; 210 + let docId: string; 202 211 203 212 beforeAll(async () => { 204 213 const res = await fetch(`${baseUrl}/api/documents`, { ··· 206 215 headers: { 'Content-Type': 'application/json' }, 207 216 body: JSON.stringify({ type: 'doc', name_encrypted: 'c2hhcmUtdGVzdA==' }), 208 217 }); 209 - const data = await res.json(); 218 + const data = await res.json() as Record<string, unknown>; 210 219 docId = data.id; 211 220 }); 212 221 ··· 217 226 body: JSON.stringify({ share_mode: 'view' }), 218 227 }); 219 228 expect(res.status).toBe(200); 220 - const data = await res.json(); 229 + const data = await res.json() as Record<string, unknown>; 221 230 expect(data.share_mode).toBe('view'); 222 231 }); 223 232 ··· 228 237 body: JSON.stringify({ share_mode: 'edit' }), 229 238 }); 230 239 expect(res.status).toBe(200); 231 - const data = await res.json(); 240 + const data = await res.json() as Record<string, unknown>; 232 241 expect(data.share_mode).toBe('edit'); 233 242 }); 234 243 ··· 249 258 body: JSON.stringify({ expires_at: futureDate }), 250 259 }); 251 260 expect(res.status).toBe(200); 252 - const data = await res.json(); 261 + const data = await res.json() as Record<string, unknown>; 253 262 expect(data.expires_at).toBeTruthy(); 254 263 }); 255 264 ··· 260 269 body: JSON.stringify({ expires_at: null }), 261 270 }); 262 271 expect(res.status).toBe(200); 263 - const data = await res.json(); 272 + const data = await res.json() as Record<string, unknown>; 264 273 expect(data.expires_at).toBeNull(); 265 274 }); 266 275 ··· 290 299 }); 291 300 292 301 const docRes = await fetch(`${baseUrl}/api/documents/${docId}`); 293 - const doc = await docRes.json(); 302 + const doc = await docRes.json() as Record<string, unknown>; 294 303 expect(doc.share_mode).toBe('view'); 295 304 }); 296 305 }); ··· 324 333 // Try to load snapshot — should be 410 325 334 const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`); 326 335 expect(res.status).toBe(410); 327 - const data = await res.json(); 336 + const data = await res.json() as Record<string, unknown>; 328 337 expect(data.error).toMatch(/expired/i); 329 338 }); 330 339 ··· 383 392 * These test the pure logic functions that will be in share-dialog.js 384 393 */ 385 394 386 - function buildShareUrl(baseUrl, docType, docId, keyString, mode) { 395 + function buildShareUrl(baseUrl: string, docType: string, docId: string, keyString: string, mode: string): string { 387 396 const url = `${baseUrl}/${docType}/${docId}#${keyString}`; 388 397 if (mode === 'view') { 389 398 return url + '?mode=view'; ··· 391 400 return url; 392 401 } 393 402 394 - function parseViewMode(searchParams) { 403 + function parseViewMode(searchParams: string | null): boolean { 395 404 return searchParams === 'view'; 396 405 } 397 406 398 - function getExpiryLabel(expiryOption) { 399 - const labels = { 407 + function getExpiryLabel(expiryOption: string): string { 408 + const labels: Record<string, string> = { 400 409 'none': 'No expiry', 401 410 '1h': '1 hour', 402 411 '1d': '1 day', ··· 406 415 return labels[expiryOption] || 'No expiry'; 407 416 } 408 417 409 - function computeExpiryDate(option) { 418 + function computeExpiryDate(option: string | null): string | null { 410 419 if (option === 'none' || !option) return null; 411 420 const now = Date.now(); 412 - const durations = { 421 + const durations: Record<string, number> = { 413 422 '1h': 60 * 60 * 1000, 414 423 '1d': 24 * 60 * 60 * 1000, 415 424 '7d': 7 * 24 * 60 * 60 * 1000, 416 425 '30d': 30 * 24 * 60 * 60 * 1000, 417 426 }; 418 - if (!durations[option]) return null; 419 - return new Date(now + durations[option]).toISOString(); 427 + const dur = durations[option]; 428 + if (!dur) return null; 429 + return new Date(now + dur).toISOString(); 420 430 } 421 431 422 432 it('builds edit share URL without mode param', () => { ··· 484 494 485 495 // Need these for the HTML tests 486 496 import { readFileSync } from 'fs'; 487 - import { resolve } from 'path'; 497 + import { resolve, dirname } from 'path'; 498 + import { fileURLToPath } from 'url'; 499 + 500 + const __dirname = dirname(fileURLToPath(import.meta.url)); 488 501 489 502 // --- Share dialog HTML tests --- 490 503 describe('Share dialog markup', () => {
tests/sheets-columns.test.js tests/sheets-columns.test.ts
tests/sheets-logic.test.js tests/sheets-logic.test.ts
tests/slash-commands.test.js tests/slash-commands.test.ts
tests/spell-check.test.js tests/spell-check.test.ts
tests/status-bar.test.js tests/status-bar.test.ts
+21 -10
tests/suggesting-mode.test.js tests/suggesting-mode.test.ts
··· 66 66 67 67 describe('Suggestion mark attribute parsing', () => { 68 68 // Mirror the parseHTML/renderHTML logic from the mark extensions 69 - function parseSuggestionAttrs(element) { 69 + interface ParsedAttrs { 70 + suggestionId: string | null; 71 + author: string | null; 72 + timestamp: string | null; 73 + type: string | null; 74 + } 75 + 76 + interface MockElement { 77 + getAttribute: (name: string) => string | null; 78 + } 79 + 80 + function parseSuggestionAttrs(element: MockElement): ParsedAttrs { 70 81 return { 71 82 suggestionId: element.getAttribute('data-suggestion-id'), 72 83 author: element.getAttribute('data-suggestion-author'), ··· 75 86 }; 76 87 } 77 88 78 - function renderSuggestionAttrs(attrs) { 89 + function renderSuggestionAttrs(attrs: ParsedAttrs): Record<string, string | null> { 79 90 return { 80 91 'data-suggestion-id': attrs.suggestionId, 81 92 'data-suggestion-author': attrs.author, ··· 85 96 } 86 97 87 98 it('parses suggestion attributes from element', () => { 88 - const el = { 89 - getAttribute: (name) => { 90 - const map = { 99 + const el: MockElement = { 100 + getAttribute: (name: string): string | null => { 101 + const map: Record<string, string> = { 91 102 'data-suggestion-id': 'abc-123', 92 103 'data-suggestion-author': 'alice', 93 104 'data-suggestion-timestamp': '2026-01-01T00:00:00Z', ··· 117 128 }); 118 129 119 130 describe('SuggestionSession', () => { 120 - let clock; 121 - let session; 131 + let clock: { value: number }; 132 + let session: SuggestionSession; 122 133 123 134 beforeEach(() => { 124 135 clock = { value: 1000 }; ··· 253 264 }); 254 265 255 266 describe('SuggestionManager', () => { 256 - let manager; 267 + let manager: SuggestionManager; 257 268 258 269 beforeEach(() => { 259 270 manager = new SuggestionManager(); ··· 440 451 }); 441 452 442 453 describe('SuggestionManager + SuggestionSession integration', () => { 443 - let clock; 444 - let manager; 454 + let clock: { value: number }; 455 + let manager: SuggestionManager; 445 456 446 457 beforeEach(() => { 447 458 clock = { value: 1000 };
tests/tab-handler-extended.test.js tests/tab-handler-extended.test.ts
tests/tab-support.test.js tests/tab-support.test.ts
tests/table-toolbar.test.js tests/table-toolbar.test.ts
+39 -37
tests/version-history.test.js tests/version-history.test.ts
··· 10 10 * - Server-side version API (tested separately in server tests) 11 11 */ 12 12 13 - // We test the pure logic module: src/lib/version-history.js 13 + // We test the pure logic module: src/lib/version-history.ts 14 14 import { 15 15 VersionManager, 16 16 computeWordCount, ··· 58 58 }); 59 59 60 60 describe('VersionManager', () => { 61 - let manager; 61 + let manager: VersionManager; 62 62 63 63 beforeEach(() => { 64 64 manager = new VersionManager({ ··· 80 80 const now = Date.now(); 81 81 manager.addVersion(snapshot, { author: 'alice', wordCount: 100 }); 82 82 const versions = manager.getVersions(); 83 - expect(versions[0].author).toBe('alice'); 84 - expect(versions[0].wordCount).toBe(100); 85 - expect(versions[0].timestamp).toBeGreaterThanOrEqual(now); 83 + expect(versions[0]!.author).toBe('alice'); 84 + expect(versions[0]!.wordCount).toBe(100); 85 + expect(versions[0]!.timestamp).toBeGreaterThanOrEqual(now); 86 86 }); 87 87 88 88 it('versions are ordered newest first', () => { ··· 90 90 manager.addVersion(new Uint8Array([2]), { author: 'bob', wordCount: 20 }); 91 91 manager.addVersion(new Uint8Array([3]), { author: 'carol', wordCount: 30 }); 92 92 const versions = manager.getVersions(); 93 - expect(versions[0].wordCount).toBe(30); 94 - expect(versions[2].wordCount).toBe(10); 93 + expect(versions[0]!.wordCount).toBe(30); 94 + expect(versions[2]!.wordCount).toBe(10); 95 95 }); 96 96 97 97 it('computes word count delta between consecutive versions', () => { ··· 103 103 // carol vs bob: 12 - 15 = -3 104 104 // bob vs alice: 15 - 10 = +5 105 105 // alice (first version): +10 106 - expect(versions[0].wordCountDelta).toBe('-3'); 107 - expect(versions[1].wordCountDelta).toBe('+5'); 108 - expect(versions[2].wordCountDelta).toBe('+10'); 106 + expect(versions[0]!.wordCountDelta).toBe('-3'); 107 + expect(versions[1]!.wordCountDelta).toBe('+5'); 108 + expect(versions[2]!.wordCountDelta).toBe('+10'); 109 109 }); 110 110 }); 111 111 ··· 180 180 const versions = smallManager.getVersions(); 181 181 expect(versions).toHaveLength(3); 182 182 // newest first: d(40), c(30), b(20) — a(10) was pruned 183 - expect(versions[0].wordCount).toBe(40); 184 - expect(versions[2].wordCount).toBe(20); 183 + expect(versions[0]!.wordCount).toBe(40); 184 + expect(versions[2]!.wordCount).toBe(20); 185 185 }); 186 186 }); 187 187 ··· 190 190 manager.addVersion(new Uint8Array([1]), { author: 'alice', wordCount: 10 }); 191 191 manager.addVersion(new Uint8Array([2]), { author: 'bob', wordCount: 20 }); 192 192 const versions = manager.getVersions(); 193 - const id = versions[0].id; 193 + const id = versions[0]!.id; 194 194 const version = manager.getVersion(id); 195 195 expect(version).toBeTruthy(); 196 - expect(version.author).toBe('bob'); 196 + expect(version!.author).toBe('bob'); 197 197 }); 198 198 199 199 it('returns null for unknown id', () => { ··· 206 206 const data = new Uint8Array([10, 20, 30]); 207 207 manager.addVersion(data, { author: 'alice', wordCount: 5 }); 208 208 const versions = manager.getVersions(); 209 - const snapshot = manager.getSnapshot(versions[0].id); 209 + const snapshot = manager.getSnapshot(versions[0]!.id); 210 210 expect(snapshot).toEqual(data); 211 211 }); 212 212 ··· 218 218 219 219 describe('Version History — Server API', () => { 220 220 // Tests for the server-side version endpoints 221 - let baseUrl; 222 - let server; 221 + let baseUrl: string; 222 + let server: import('http').Server; 223 223 224 224 beforeEach(async () => { 225 225 const { createServer } = await import('http'); ··· 257 257 countVersions: db.prepare('SELECT COUNT(*) as count FROM versions WHERE document_id = ?'), 258 258 }; 259 259 260 - function pruneVersions(docId) { 261 - const { count } = stmts.countVersions.get(docId); 260 + function pruneVersions(docId: string): void { 261 + const row = stmts.countVersions.get(docId) as { count: number } | undefined; 262 + const count = row?.count ?? 0; 262 263 if (count > 50) { 263 264 const excess = count - 50; 264 265 db.prepare(` ··· 274 275 const app = express(); 275 276 276 277 // Version routes need raw body parsing — register them before json middleware 277 - app.post('/api/documents/:id/versions', express.raw({ limit: '50mb', type: '*/*' }), (req, res) => { 278 + app.post('/api/documents/:id/versions', express.raw({ limit: '50mb', type: '*/*' }), (req: import('express').Request<{ id: string }>, res: import('express').Response) => { 278 279 try { 279 280 const docId = req.params.id; 280 281 const id = randomUUID(); 281 - const metadata = req.headers['x-version-metadata'] || null; 282 + const metadata = (req.headers['x-version-metadata'] as string | undefined) || null; 282 283 stmts.insertVersion.run(id, docId, req.body, metadata); 283 284 pruneVersions(docId); 284 285 res.json({ id }); 285 - } catch (err) { 286 - res.status(500).json({ error: err.message }); 286 + } catch (err: unknown) { 287 + const message = err instanceof Error ? err.message : 'Unknown error'; 288 + res.status(500).json({ error: message }); 287 289 } 288 290 }); 289 291 290 - app.get('/api/documents/:id/versions', (req, res) => { 291 - const versions = stmts.getVersions.all(req.params.id); 292 + app.get('/api/documents/:id/versions', (req: import('express').Request<{ id: string }>, res: import('express').Response) => { 293 + const versions = stmts.getVersions.all(req.params.id) as Array<{ id: string; document_id: string; created_at: string; metadata: string | null }>; 292 294 res.json(versions.map(v => ({ 293 295 ...v, 294 - metadata: v.metadata ? JSON.parse(v.metadata) : null, 296 + metadata: v.metadata ? JSON.parse(v.metadata) as unknown : null, 295 297 }))); 296 298 }); 297 299 298 - app.get('/api/documents/:id/versions/:versionId', (req, res) => { 299 - const row = stmts.getVersionSnapshot.get(req.params.versionId, req.params.id); 300 - if (!row || !row.snapshot) return res.status(404).json({ error: 'Version not found' }); 300 + app.get('/api/documents/:id/versions/:versionId', (req: import('express').Request<{ id: string; versionId: string }>, res: import('express').Response) => { 301 + const row = stmts.getVersionSnapshot.get(req.params.versionId, req.params.id) as { snapshot: Buffer } | undefined; 302 + if (!row || !row.snapshot) { res.status(404).json({ error: 'Version not found' }); return; } 301 303 res.type('application/octet-stream').send(row.snapshot); 302 304 }); 303 305 304 306 server = createServer(app); 305 - await new Promise(resolve => server.listen(0, resolve)); 306 - baseUrl = `http://localhost:${server.address().port}`; 307 + await new Promise<void>(resolve => server.listen(0, resolve)); 308 + const addr = server.address(); 309 + baseUrl = `http://localhost:${typeof addr === 'object' && addr ? addr.port : 0}`; 307 310 }); 308 311 309 312 afterEach(async () => { 310 - if (server) await new Promise(r => server.close(r)); 313 + if (server) await new Promise<void>(r => server.close(() => r())); 311 314 }); 312 315 313 316 it('POST creates a version', async () => { ··· 317 320 headers: { 'Content-Type': 'application/octet-stream' }, 318 321 }); 319 322 expect(res.status).toBe(200); 320 - const data = await res.json(); 323 + const data = await res.json() as { id: string }; 321 324 expect(data.id).toBeTruthy(); 322 325 }); 323 326 ··· 341 344 }); 342 345 343 346 const res = await fetch(`${baseUrl}/api/documents/test-doc/versions`); 344 - const versions = await res.json(); 347 + const versions = await res.json() as Array<{ metadata: { wordCount: number } }>; 345 348 expect(versions).toHaveLength(2); 346 - expect(versions[0].metadata.wordCount).toBe(20); 349 + expect(versions[0]!.metadata.wordCount).toBe(20); 347 350 }); 348 351 349 352 it('GET retrieves a specific version snapshot', async () => { ··· 352 355 body: Buffer.from([42, 43, 44]), 353 356 headers: { 'Content-Type': 'application/octet-stream' }, 354 357 }); 355 - const { id } = await createRes.json(); 358 + const { id } = await createRes.json() as { id: string }; 356 359 357 360 const res = await fetch(`${baseUrl}/api/documents/test-doc/versions/${id}`); 358 361 expect(res.status).toBe(200); ··· 365 368 expect(res.status).toBe(404); 366 369 }); 367 370 }); 368 -
tests/virtual-scroll.test.js tests/virtual-scroll.test.ts
tests/xlsx-import-styles.test.js tests/xlsx-import-styles.test.ts
tests/xlsx-import.test.js tests/xlsx-import.test.ts
tests/zen-mode.test.js tests/zen-mode.test.ts
+25
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ES2022", 5 + "moduleResolution": "bundler", 6 + "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 + "strict": true, 8 + "noImplicitAny": true, 9 + "noUncheckedIndexedAccess": true, 10 + "esModuleInterop": true, 11 + "skipLibCheck": true, 12 + "forceConsistentCasingInFileNames": true, 13 + "resolveJsonModule": true, 14 + "isolatedModules": true, 15 + "noEmit": true, 16 + "declaration": false, 17 + "jsx": "preserve", 18 + "outDir": "./dist", 19 + "rootDir": ".", 20 + "baseUrl": ".", 21 + "paths": {} 22 + }, 23 + "include": ["src/**/*.ts", "server/**/*.ts", "tests/**/*.ts"], 24 + "exclude": ["node_modules", "dist"] 25 + }
+10
tsconfig.server.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "compilerOptions": { 4 + "module": "ES2022", 5 + "moduleResolution": "bundler", 6 + "lib": ["ES2022"], 7 + "outDir": "./dist-server" 8 + }, 9 + "include": ["server/**/*.ts"] 10 + }
+3 -1
vite.config.js vite.config.ts
··· 1 1 import { defineConfig } from 'vite'; 2 2 import { resolve } from 'path'; 3 3 4 + const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, ''); 5 + 4 6 export default defineConfig({ 5 7 root: 'src', 6 8 publicDir: '../public', 7 9 test: { 8 10 root: '.', 9 - include: ['tests/**/*.test.js'], 11 + include: ['tests/**/*.test.{js,ts}'], 10 12 }, 11 13 build: { 12 14 outDir: '../dist',