domma-cms 1.8.32 → 1.8.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -1
- package/plugins/_lib/admin/games/kit.js +329 -124
- package/plugins/_lib/games/http.js +11 -1
- package/plugins/_lib/games/plays.js +28 -7
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -1
- package/plugins/crossword/admin/views/crossword.js +337 -205
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -1
- package/plugins/crossword/plugin.js +38 -5
- package/plugins/crossword/plugin.json +4 -3
- package/plugins/crossword/public/crossword.css +36 -1
- package/plugins/crossword/public/crossword.js +468 -6
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +147 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -1
- package/plugins/quiz/admin/views/quiz.js +423 -245
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -1
- package/plugins/quiz/plugin.js +39 -5
- package/plugins/quiz/plugin.json +4 -3
- package/plugins/quiz/public/quiz.css +36 -1
- package/plugins/quiz/public/quiz.js +298 -6
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- package/plugins/theme-roller/tests/view.test.js +55 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docs storage and the rules around it: documents on disk, who may do what
|
|
3
|
+
* with one, per-user stars and recents, edit locks, the public-link index and
|
|
4
|
+
* the sanitiser every stored body goes through.
|
|
5
|
+
*
|
|
6
|
+
* Shared by plugin.js (the admin API) and plugin.public.js (the public link
|
|
7
|
+
* page). Both are loaded by the same process, so the module state here - the
|
|
8
|
+
* running settings, the lock table, the caches - is one copy between them.
|
|
9
|
+
*
|
|
10
|
+
* Files only: data/documents/<id>.json, data/versions/<id>/<n>.json,
|
|
11
|
+
* data/user-state.json - and, for a PDF document (2.13), its file at
|
|
12
|
+
* data/files/<id>.pdf with each earlier upload at data/versions/<id>/<n>.pdf.
|
|
13
|
+
* Writes are whole-file and single-process, as before.
|
|
14
|
+
*
|
|
15
|
+
* @module docs/lib/store
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'fs/promises';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import crypto from 'crypto';
|
|
21
|
+
import {fileURLToPath} from 'url';
|
|
22
|
+
import sanitizeHtml from 'sanitize-html';
|
|
23
|
+
import {countWords, preview} from '../admin/lib/convert.js';
|
|
24
|
+
import {docsRegistry} from './extensions.js';
|
|
25
|
+
|
|
26
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
// DOCS_DATA_DIR: tests point this at a temp dir so they never touch a real site's documents.
|
|
28
|
+
export const DATA_DIR = process.env.DOCS_DATA_DIR || path.join(here, '..', 'data');
|
|
29
|
+
const DOCS_DIR = path.join(DATA_DIR, 'documents');
|
|
30
|
+
const VERSIONS_DIR = path.join(DATA_DIR, 'versions');
|
|
31
|
+
const FILES_DIR = path.join(DATA_DIR, 'files');
|
|
32
|
+
const USER_STATE = path.join(DATA_DIR, 'user-state.json');
|
|
33
|
+
|
|
34
|
+
// ------------------------------------------------------------------ settings
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The running settings, shared by both halves of the plugin. plugin.js
|
|
38
|
+
* fills it at boot and on every PUT /settings; the public page reads it.
|
|
39
|
+
*/
|
|
40
|
+
export const runtime = {
|
|
41
|
+
config: {
|
|
42
|
+
scope: 'user',
|
|
43
|
+
autoSaveInterval: 30000,
|
|
44
|
+
maxVersions: 50,
|
|
45
|
+
publicLinks: true,
|
|
46
|
+
trashDays: 30
|
|
47
|
+
},
|
|
48
|
+
/**
|
|
49
|
+
* Bumped after every finished document or star write - through a route or not (Docs Pro writes
|
|
50
|
+
* through `services`). GET /count only caches an answer read within one generation (2.10).
|
|
51
|
+
*/
|
|
52
|
+
writeGen: 0
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** A document or a star changed: answers cached before now are stale. */
|
|
56
|
+
export function bumpWriteGen() { runtime.writeGen++; }
|
|
57
|
+
|
|
58
|
+
// ----------------------------------------------------------------- sanitise
|
|
59
|
+
|
|
60
|
+
const TAGS = ['p', 'div', 'span', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'b', 'em', 'i', 'u', 's',
|
|
61
|
+
'del', 'strike', 'ins', 'sub', 'sup', 'mark', 'code', 'pre', 'kbd', 'blockquote', 'ul', 'ol', 'li', 'a', 'img',
|
|
62
|
+
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col', 'figure', 'figcaption'];
|
|
63
|
+
|
|
64
|
+
const STYLE_VALUE = {
|
|
65
|
+
'text-align': [/^(left|right|center|justify)$/],
|
|
66
|
+
'background-color': [/^#[0-9a-f]{3,8}$/i, /^rgba?\([\d\s.,%]+\)$/i, /^transparent$/],
|
|
67
|
+
color: [/^#[0-9a-f]{3,8}$/i, /^rgba?\([\d\s.,%]+\)$/i],
|
|
68
|
+
width: [/^\d+(\.\d+)?(px|%|em|rem)$/],
|
|
69
|
+
'margin-left': [/^\d+(\.\d+)?(px|em|rem)$/],
|
|
70
|
+
'padding-left': [/^\d+(\.\d+)?(px|em|rem)$/]
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Extra tags/attributes an add-on (Docs Pro) needs kept - comment anchors and the like. */
|
|
74
|
+
const extraRules = {tags: new Set(), attributes: {}};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Let an extension keep more markup through the sanitiser.
|
|
78
|
+
*
|
|
79
|
+
* @param {{tags?:string[], attributes?:Object<string,string[]>}} rules
|
|
80
|
+
*/
|
|
81
|
+
export function allowMarkup(rules = {}) {
|
|
82
|
+
for (const t of rules.tags || []) extraRules.tags.add(t);
|
|
83
|
+
for (const [tag, attrs] of Object.entries(rules.attributes || {})) {
|
|
84
|
+
extraRules.attributes[tag] = [...new Set([...(extraRules.attributes[tag] || []), ...attrs])];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What a document body may contain. Everything a document is written with,
|
|
90
|
+
* nothing that runs: no scripts, no event handlers, no javascript: links, no
|
|
91
|
+
* forms or frames, and only the few inline styles the editor itself writes.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} html
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
/**
|
|
97
|
+
* A body as readers see it (2.5): pending suggestions shown as not made -
|
|
98
|
+
* suggested words out, suggested deletions kept as plain text. Docs does this
|
|
99
|
+
* itself, so a lapsed Docs Pro can never put unaccepted words on a public page.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} html sanitised
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function withoutSuggestions(html) {
|
|
105
|
+
const s = String(html ?? '');
|
|
106
|
+
if (!s.includes('data-sug')) return s;
|
|
107
|
+
return sanitizeHtml(s, {
|
|
108
|
+
// Already sanitised: the same tags, every attribute it kept.
|
|
109
|
+
allowedTags: [...TAGS, ...extraRules.tags],
|
|
110
|
+
allowedAttributes: false,
|
|
111
|
+
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
|
112
|
+
allowedSchemesByTag: {img: ['http', 'https', 'data']},
|
|
113
|
+
exclusiveFilter: (frame) => frame.tag === 'ins' && frame.attribs?.['data-sug'] !== undefined,
|
|
114
|
+
transformTags: {del: (tag, attribs) => (attribs['data-sug'] !== undefined ? {tagName: 'span', attribs: {}} : {tagName: tag, attribs})}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function sanitizeDoc(html) {
|
|
119
|
+
const attrs = {
|
|
120
|
+
a: ['href', 'title', 'target', 'rel'],
|
|
121
|
+
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
122
|
+
td: ['colspan', 'rowspan'],
|
|
123
|
+
th: ['colspan', 'rowspan', 'scope'],
|
|
124
|
+
ol: ['start', 'type'],
|
|
125
|
+
ul: ['data-type'],
|
|
126
|
+
li: ['data-checked'],
|
|
127
|
+
code: ['class'],
|
|
128
|
+
pre: ['class'],
|
|
129
|
+
// Suggested edits (2.5, Docs Pro) keep their marks even while Pro is off - so they stay suggestions.
|
|
130
|
+
ins: ['data-sug', 'data-by', 'data-name', 'data-at'],
|
|
131
|
+
del: ['data-sug', 'data-by', 'data-name', 'data-at'],
|
|
132
|
+
'*': ['style']
|
|
133
|
+
};
|
|
134
|
+
for (const [tag, list] of Object.entries(extraRules.attributes)) attrs[tag] = [...(attrs[tag] || []), ...list];
|
|
135
|
+
return sanitizeHtml(String(html ?? ''), {
|
|
136
|
+
allowedTags: [...TAGS, ...extraRules.tags],
|
|
137
|
+
allowedAttributes: attrs,
|
|
138
|
+
allowedClasses: {code: [/^language-[\w-]+$/], pre: [/^language-[\w-]+$/]},
|
|
139
|
+
allowedStyles: {'*': STYLE_VALUE},
|
|
140
|
+
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
|
141
|
+
allowedSchemesByTag: {img: ['http', 'https', 'data']},
|
|
142
|
+
allowProtocolRelative: false,
|
|
143
|
+
transformTags: {
|
|
144
|
+
// A link out of a document opens in a new tab and hands the
|
|
145
|
+
// target page nothing back.
|
|
146
|
+
a: (tagName, attribs) => {
|
|
147
|
+
const out = {...attribs};
|
|
148
|
+
if (out.href && /^https?:/i.test(out.href)) { out.target = '_blank'; out.rel = 'noopener noreferrer'; }
|
|
149
|
+
return {tagName, attribs: out};
|
|
150
|
+
},
|
|
151
|
+
font: 'span'
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ------------------------------------------------------------------- files
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {string} id
|
|
160
|
+
* @returns {boolean}
|
|
161
|
+
*/
|
|
162
|
+
export function validId(id) {
|
|
163
|
+
return typeof id === 'string' && /^[A-Za-z0-9-]{8,64}$/.test(id);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What kind of document this is (2.13): 'pdf' - a file, shown as it is - or
|
|
168
|
+
* 'html' - a body written in the editor. Everything that renders, exports or
|
|
169
|
+
* edits a body asks this first.
|
|
170
|
+
*
|
|
171
|
+
* @param {object} doc
|
|
172
|
+
* @returns {'html'|'pdf'}
|
|
173
|
+
*/
|
|
174
|
+
export function kindOf(doc) {
|
|
175
|
+
return doc?.format === 'pdf' ? 'pdf' : 'html';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Where a PDF document's file lives. */
|
|
179
|
+
export function filePath(id) {
|
|
180
|
+
return path.join(FILES_DIR, `${id}.pdf`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Where an earlier upload of a PDF document is kept, beside its version record. */
|
|
184
|
+
export function versionFilePath(id, num) {
|
|
185
|
+
return path.join(VERSIONS_DIR, id, `${num}.pdf`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A PDF document's bytes, or null. */
|
|
189
|
+
export async function readFileBytes(id) {
|
|
190
|
+
if (!validId(id)) return null;
|
|
191
|
+
try { return await fs.readFile(filePath(id)); } catch { return null; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Write a PDF document's file atomically (tmp + rename). The record is written separately. */
|
|
195
|
+
export async function writeFileBytes(id, buf) {
|
|
196
|
+
if (!validId(id)) throw new Error('Bad document id');
|
|
197
|
+
await fs.mkdir(FILES_DIR, {recursive: true});
|
|
198
|
+
const file = filePath(id);
|
|
199
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
200
|
+
await fs.writeFile(tmp, buf);
|
|
201
|
+
await fs.rename(tmp, file);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Copy one PDF document's file to another document (a duplicate). */
|
|
205
|
+
export async function copyFileBytes(fromId, toId) {
|
|
206
|
+
if (!validId(fromId) || !validId(toId)) return false;
|
|
207
|
+
await fs.mkdir(FILES_DIR, {recursive: true});
|
|
208
|
+
try { await fs.copyFile(filePath(fromId), filePath(toId)); return true; } catch { return false; }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function readDoc(id) {
|
|
212
|
+
if (!validId(id)) return null;
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(await fs.readFile(path.join(DOCS_DIR, `${id}.json`), 'utf8'));
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Serialise writes per document - two saves racing must not interleave. */
|
|
221
|
+
const writing = new Map();
|
|
222
|
+
|
|
223
|
+
export async function writeDoc(doc) {
|
|
224
|
+
await fs.mkdir(DOCS_DIR, {recursive: true});
|
|
225
|
+
const file = path.join(DOCS_DIR, `${doc.id}.json`);
|
|
226
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
227
|
+
const prev = writing.get(doc.id) || Promise.resolve();
|
|
228
|
+
const job = prev.catch(() => {}).then(async () => {
|
|
229
|
+
await fs.writeFile(tmp, JSON.stringify(doc, null, 2));
|
|
230
|
+
await fs.rename(tmp, file);
|
|
231
|
+
});
|
|
232
|
+
writing.set(doc.id, job);
|
|
233
|
+
try { await job; } finally { if (writing.get(doc.id) === job) writing.delete(doc.id); bumpWriteGen(); }
|
|
234
|
+
linkIndex = null;
|
|
235
|
+
return doc;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Change one document safely: re-read it inside that document's write
|
|
240
|
+
* chain, let `fn` return the new version (or null for no change), write it.
|
|
241
|
+
* Every route that edits a document goes through here, so a share saved
|
|
242
|
+
* while an autosave lands can never write back a stale body.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} id
|
|
245
|
+
* @param {Function} fn (doc) => newDoc | null (may be async; keep it short)
|
|
246
|
+
* @returns {Promise<object|null>} the document as written, or as it was
|
|
247
|
+
*/
|
|
248
|
+
export async function mutateDoc(id, fn) {
|
|
249
|
+
if (!validId(id)) return null;
|
|
250
|
+
const prev = writing.get(id) || Promise.resolve();
|
|
251
|
+
let result = null;
|
|
252
|
+
const job = prev.catch(() => {}).then(async () => {
|
|
253
|
+
const doc = await readDoc(id);
|
|
254
|
+
if (!doc) return;
|
|
255
|
+
const next = await fn(doc);
|
|
256
|
+
if (!next) { result = doc; return; }
|
|
257
|
+
await fs.mkdir(DOCS_DIR, {recursive: true});
|
|
258
|
+
const file = path.join(DOCS_DIR, `${id}.json`);
|
|
259
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
260
|
+
await fs.writeFile(tmp, JSON.stringify(next, null, 2));
|
|
261
|
+
await fs.rename(tmp, file);
|
|
262
|
+
linkIndex = null;
|
|
263
|
+
result = next;
|
|
264
|
+
});
|
|
265
|
+
writing.set(id, job);
|
|
266
|
+
try { await job; } finally { if (writing.get(id) === job) writing.delete(id); bumpWriteGen(); }
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function removeDoc(id) {
|
|
271
|
+
if (!validId(id)) return;
|
|
272
|
+
await fs.rm(path.join(DOCS_DIR, `${id}.json`), {force: true});
|
|
273
|
+
await fs.rm(path.join(VERSIONS_DIR, id), {recursive: true, force: true});
|
|
274
|
+
await fs.rm(filePath(id), {force: true});
|
|
275
|
+
linkIndex = null;
|
|
276
|
+
bumpWriteGen();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function listDocs() {
|
|
280
|
+
await fs.mkdir(DOCS_DIR, {recursive: true});
|
|
281
|
+
const files = (await fs.readdir(DOCS_DIR)).filter(f => f.endsWith('.json'));
|
|
282
|
+
const docs = await Promise.all(files.map(async (f) => {
|
|
283
|
+
try { return JSON.parse(await fs.readFile(path.join(DOCS_DIR, f), 'utf8')); } catch { return null; }
|
|
284
|
+
}));
|
|
285
|
+
return docs.filter(Boolean);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A new document, stamped. Bodies are sanitised on every way in. A PDF
|
|
290
|
+
* document (2.13) has `format: 'pdf'` and a `file` record {name, size, pages,
|
|
291
|
+
* sha256, uploadedAt, uploadedBy}; its `content` is the text read out of the
|
|
292
|
+
* file (for search and the excerpt), sanitised like any body.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} fields
|
|
295
|
+
* @param {object} user request.user
|
|
296
|
+
* @returns {object}
|
|
297
|
+
*/
|
|
298
|
+
export function newDoc({title, content = '', folderId = null, tags = [], format = 'html', file = null}, user) {
|
|
299
|
+
const now = new Date().toISOString();
|
|
300
|
+
const body = sanitizeDoc(content);
|
|
301
|
+
const pdf = format === 'pdf' && file;
|
|
302
|
+
return {
|
|
303
|
+
id: crypto.randomUUID(),
|
|
304
|
+
title: cleanTitle(title),
|
|
305
|
+
content: body,
|
|
306
|
+
format: pdf ? 'pdf' : 'html',
|
|
307
|
+
...(pdf ? {file: fileRecord(file, user, now)} : {}),
|
|
308
|
+
folderId: folderId || null,
|
|
309
|
+
tags: Array.isArray(tags) ? tags.map(String).slice(0, 50) : [],
|
|
310
|
+
userId: user.id,
|
|
311
|
+
shares: [],
|
|
312
|
+
publicLink: null,
|
|
313
|
+
trashedAt: null,
|
|
314
|
+
wordCount: countWords(body),
|
|
315
|
+
createdAt: now,
|
|
316
|
+
updatedAt: now,
|
|
317
|
+
updatedBy: user.id
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The file record a PDF document carries - whitelisted, never spread from a request. */
|
|
322
|
+
export function fileRecord(file, user, at = new Date().toISOString()) {
|
|
323
|
+
return {
|
|
324
|
+
name: String(file?.name || 'document.pdf').slice(0, 160),
|
|
325
|
+
size: Math.max(0, Number(file?.size) || 0),
|
|
326
|
+
pages: Math.max(0, Math.round(Number(file?.pages) || 0)),
|
|
327
|
+
sha256: /^[a-f0-9]{64}$/.test(String(file?.sha256 || '')) ? file.sha256 : '',
|
|
328
|
+
encrypted: file?.encrypted === true,
|
|
329
|
+
uploadedAt: at,
|
|
330
|
+
uploadedBy: user?.id || null
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function cleanTitle(title) {
|
|
335
|
+
const t = String(title ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
336
|
+
return t || 'Untitled';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ----------------------------------------------------------------- versions
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Keep the content a document is about to lose, then trim its history to
|
|
343
|
+
* the newest `keep`. Numbers keep climbing; only the oldest files go.
|
|
344
|
+
*
|
|
345
|
+
* @param {object} doc the document BEFORE the change
|
|
346
|
+
* @param {number} keep
|
|
347
|
+
* @param {string} [by] who made the change that replaced it
|
|
348
|
+
*/
|
|
349
|
+
export async function snapshotVersion(doc, keep, by = null) {
|
|
350
|
+
const dir = path.join(VERSIONS_DIR, doc.id);
|
|
351
|
+
await fs.mkdir(dir, {recursive: true});
|
|
352
|
+
const nums = [...new Set((await fs.readdir(dir).catch(() => [])).filter(f => f.endsWith('.json')).map(f => parseInt(f, 10)).filter(n => !isNaN(n)))].sort((a, b) => a - b);
|
|
353
|
+
const next = nums.length ? nums[nums.length - 1] + 1 : 1;
|
|
354
|
+
// A PDF document's version is the file it had (2.13): copied first, so a record never names a file that is not there.
|
|
355
|
+
const pdf = kindOf(doc) === 'pdf';
|
|
356
|
+
if (pdf) { try { await fs.copyFile(filePath(doc.id), versionFilePath(doc.id, next)); } catch { /* no file yet: the record alone */ } }
|
|
357
|
+
await fs.writeFile(path.join(dir, `${next}.json`), JSON.stringify({
|
|
358
|
+
num: next, title: doc.title, content: doc.content, createdAt: new Date().toISOString(),
|
|
359
|
+
...(pdf ? {format: 'pdf', file: doc.file || null} : {}),
|
|
360
|
+
savedBy: doc.updatedBy || doc.userId || null, replacedBy: by,
|
|
361
|
+
// 2.12: a change credited by name (Wiki Pro's approved suggestion from a visitor) - only while it is that save's.
|
|
362
|
+
...(doc.creditName && doc.creditAt && doc.creditAt === doc.updatedAt ? {savedByName: String(doc.creditName).slice(0, 80)} : {})
|
|
363
|
+
}, null, 2));
|
|
364
|
+
const all = [...nums, next];
|
|
365
|
+
for (const n of all.slice(0, Math.max(0, all.length - keep))) {
|
|
366
|
+
await fs.rm(path.join(dir, `${n}.json`), {force: true});
|
|
367
|
+
await fs.rm(path.join(dir, `${n}.pdf`), {force: true});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Put an earlier upload back as a PDF document's file (2.13) - a restore.
|
|
373
|
+
*
|
|
374
|
+
* @returns {Promise<boolean>} false when that version kept no file
|
|
375
|
+
*/
|
|
376
|
+
export async function restoreVersionFile(id, num) {
|
|
377
|
+
if (!validId(id) || !/^\d{1,7}$/.test(String(num))) return false;
|
|
378
|
+
await fs.mkdir(FILES_DIR, {recursive: true});
|
|
379
|
+
try { await fs.copyFile(versionFilePath(id, num), filePath(id)); return true; } catch { return false; }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** An earlier upload's bytes, or null. */
|
|
383
|
+
export async function readVersionFile(id, num) {
|
|
384
|
+
if (!validId(id) || !/^\d{1,7}$/.test(String(num))) return null;
|
|
385
|
+
try { return await fs.readFile(versionFilePath(id, num)); } catch { return null; }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Whether a save should keep what it replaces as a version. Not when the
|
|
390
|
+
* same person saved the document less than ten minutes ago: that text is
|
|
391
|
+
* their own work in progress (autosave), and keeping every one of those
|
|
392
|
+
* would push real history out of the retention limit.
|
|
393
|
+
*/
|
|
394
|
+
export function shouldSnapshot(doc, userId, now = Date.now()) {
|
|
395
|
+
if (doc.updatedBy !== userId) return true;
|
|
396
|
+
// 2.12: a save credited to someone else by name (an approved suggestion) is never folded into the next one.
|
|
397
|
+
if (doc.creditName && doc.creditAt && doc.creditAt === doc.updatedAt) return true;
|
|
398
|
+
const last = Date.parse(doc.updatedAt || '') || 0;
|
|
399
|
+
return now - last > 10 * 60 * 1000;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export async function listVersions(id) {
|
|
403
|
+
const dir = path.join(VERSIONS_DIR, id);
|
|
404
|
+
const files = (await fs.readdir(dir).catch(() => [])).filter(f => f.endsWith('.json'));
|
|
405
|
+
const out = [];
|
|
406
|
+
for (const f of files) {
|
|
407
|
+
try { out.push(JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))); } catch { /* skip */ }
|
|
408
|
+
}
|
|
409
|
+
return out.sort((a, b) => b.num - a.num);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export async function readVersion(id, num) {
|
|
413
|
+
if (!/^\d{1,7}$/.test(String(num))) return null;
|
|
414
|
+
try {
|
|
415
|
+
return JSON.parse(await fs.readFile(path.join(VERSIONS_DIR, id, `${num}.json`), 'utf8'));
|
|
416
|
+
} catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ------------------------------------------------------------------- access
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* What this user may do with a document.
|
|
425
|
+
*
|
|
426
|
+
* - `owner` - everything, including trash, delete, move and transfer
|
|
427
|
+
* - `editor` - edit, share, public link, send
|
|
428
|
+
* - `viewer` - read, export, duplicate into their own documents
|
|
429
|
+
* - null - nothing; the API answers 404 so a document's existence
|
|
430
|
+
* does not leak
|
|
431
|
+
*
|
|
432
|
+
* `scope: 'all'` (the old site-wide setting) still reads for everybody and
|
|
433
|
+
* lets an admin change anything.
|
|
434
|
+
*
|
|
435
|
+
* @param {object} doc
|
|
436
|
+
* @param {object} user request.user
|
|
437
|
+
* @param {boolean} isAdmin
|
|
438
|
+
* @returns {'owner'|'editor'|'suggester'|'viewer'|null} (a Suggester is a Viewer who may also suggest changes - 2.5)
|
|
439
|
+
*/
|
|
440
|
+
export function accessOf(doc, user, isAdmin = false) {
|
|
441
|
+
if (!doc || !user) return null;
|
|
442
|
+
if (doc.userId === user.id) return 'owner';
|
|
443
|
+
const share = (doc.shares || []).find(s => s.userId === user.id);
|
|
444
|
+
const siteWide = runtime.config.scope === 'all';
|
|
445
|
+
if (siteWide && isAdmin) return 'owner';
|
|
446
|
+
const own = share ? (share.role === 'editor' ? 'editor' : share.role === 'suggester' ? 'suggester' : 'viewer')
|
|
447
|
+
: siteWide ? 'viewer' : null;
|
|
448
|
+
// An add-on may give more (2.11): the Wiki makes its editors editors of its pages.
|
|
449
|
+
if (own === 'editor') return own;
|
|
450
|
+
const extra = extensionAccess(doc, user);
|
|
451
|
+
if (extra === 'editor') return 'editor';
|
|
452
|
+
return own || extra;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Why this person may not share, link or send this document (2.11), or ''.
|
|
457
|
+
* Access that comes only through an add-on is for editing and history - never
|
|
458
|
+
* for passing the document on (and a share would outlive the add-on's grant).
|
|
459
|
+
* A document an add-on restricts (a members-only wiki page) leaves Docs for nobody.
|
|
460
|
+
*/
|
|
461
|
+
export function outwardRefusal(doc, user) {
|
|
462
|
+
if (onlyThroughExtension(doc, user)) return 'You can edit this document through another tool, not share, link or send it.';
|
|
463
|
+
try { return docsRegistry().restrictionOf?.(doc) || ''; } catch { return 'This document cannot leave Docs right now.'; }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Why this person may not change this document now (2.12): an add-on's freeze (a
|
|
468
|
+
* protected wiki page), owner included - or ''. A failing registry freezes.
|
|
469
|
+
*/
|
|
470
|
+
export function frozenFor(doc, user) {
|
|
471
|
+
if (!doc || !user) return '';
|
|
472
|
+
try { return docsRegistry().frozenFor?.(doc, user) || ''; } catch { return 'This document cannot be changed right now.'; }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** What add-ons give this user on this document (2.11) - 'editor', 'viewer' or null. */
|
|
476
|
+
export function extensionAccess(doc, user) {
|
|
477
|
+
try { return docsRegistry().extraAccess?.(doc, user) || null; } catch { return null; }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Reached only through an add-on (2.11): not theirs, not shared with them, not
|
|
482
|
+
* site-wide. Such a document (a Wiki page) is in their Starred and Recent when
|
|
483
|
+
* they starred or opened it, and in no other place on Home - it was never shared
|
|
484
|
+
* with them.
|
|
485
|
+
*/
|
|
486
|
+
export function onlyThroughExtension(doc, user) {
|
|
487
|
+
if (!doc || !user || doc.userId === user.id || runtime.config.scope === 'all') return false;
|
|
488
|
+
if ((doc.shares || []).some(s => s.userId === user.id)) return false;
|
|
489
|
+
return Boolean(extensionAccess(doc, user));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export const canEdit = (a) => a === 'owner' || a === 'editor';
|
|
493
|
+
|
|
494
|
+
// --------------------------------------------------------------- user state
|
|
495
|
+
|
|
496
|
+
let userState = null;
|
|
497
|
+
|
|
498
|
+
async function loadUserState() {
|
|
499
|
+
if (userState) return userState;
|
|
500
|
+
try { userState = JSON.parse(await fs.readFile(USER_STATE, 'utf8')); } catch { userState = {}; }
|
|
501
|
+
if (!userState || typeof userState !== 'object' || Array.isArray(userState)) userState = {};
|
|
502
|
+
return userState;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
let stateWrite = Promise.resolve();
|
|
506
|
+
async function saveUserState() {
|
|
507
|
+
const data = JSON.stringify(userState, null, 2);
|
|
508
|
+
stateWrite = stateWrite.catch(() => {}).then(async () => {
|
|
509
|
+
await fs.mkdir(DATA_DIR, {recursive: true});
|
|
510
|
+
const tmp = `${USER_STATE}.${process.pid}.tmp`;
|
|
511
|
+
await fs.writeFile(tmp, data);
|
|
512
|
+
await fs.rename(tmp, USER_STATE);
|
|
513
|
+
});
|
|
514
|
+
return stateWrite;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* @param {string} userId
|
|
519
|
+
* @returns {Promise<{starred:string[], recent:Array<{id:string, at:string}>}>}
|
|
520
|
+
*/
|
|
521
|
+
export async function stateFor(userId) {
|
|
522
|
+
const all = await loadUserState();
|
|
523
|
+
const s = all[userId] || {};
|
|
524
|
+
return {starred: Array.isArray(s.starred) ? s.starred : [], recent: Array.isArray(s.recent) ? s.recent : []};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export async function setStar(userId, docId, on) {
|
|
528
|
+
const all = await loadUserState();
|
|
529
|
+
const s = await stateFor(userId);
|
|
530
|
+
const starred = s.starred.filter(id => id !== docId);
|
|
531
|
+
if (on) starred.unshift(docId);
|
|
532
|
+
all[userId] = {...s, starred: starred.slice(0, 500)};
|
|
533
|
+
try { await saveUserState(); } finally { bumpWriteGen(); }
|
|
534
|
+
return on;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export async function touchRecent(userId, docId) {
|
|
538
|
+
const all = await loadUserState();
|
|
539
|
+
const s = await stateFor(userId);
|
|
540
|
+
const recent = [{id: docId, at: new Date().toISOString()}, ...s.recent.filter(r => r.id !== docId)].slice(0, 100);
|
|
541
|
+
all[userId] = {...s, recent};
|
|
542
|
+
await saveUserState();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Forget a document everywhere once it is gone for good. */
|
|
546
|
+
export async function forgetDoc(docId) {
|
|
547
|
+
const all = await loadUserState();
|
|
548
|
+
let changed = false;
|
|
549
|
+
for (const [uid, s] of Object.entries(all)) {
|
|
550
|
+
const starred = (s.starred || []).filter(id => id !== docId);
|
|
551
|
+
const recent = (s.recent || []).filter(r => r.id !== docId);
|
|
552
|
+
if (starred.length !== (s.starred || []).length || recent.length !== (s.recent || []).length) {
|
|
553
|
+
all[uid] = {...s, starred, recent};
|
|
554
|
+
changed = true;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (changed) await saveUserState();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// -------------------------------------------------------------------- locks
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Who is editing what. In memory: a lock outlives nothing but the process,
|
|
564
|
+
* and a restart letting everyone back in is the right way round to fail.
|
|
565
|
+
* Keyed by document; held by one editor session (a browser tab).
|
|
566
|
+
*/
|
|
567
|
+
const locks = new Map();
|
|
568
|
+
export const LOCK_TTL_MS = 90 * 1000;
|
|
569
|
+
|
|
570
|
+
export function lockOf(docId, now = Date.now()) {
|
|
571
|
+
const l = locks.get(docId);
|
|
572
|
+
if (!l) return null;
|
|
573
|
+
if (l.expires <= now) { locks.delete(docId); return null; }
|
|
574
|
+
return l;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Take or refresh the lock. Refused while another session holds it, unless
|
|
579
|
+
* `force` - the owner taking over, or the same person in another tab.
|
|
580
|
+
*
|
|
581
|
+
* @returns {{ok:boolean, lock:object}}
|
|
582
|
+
*/
|
|
583
|
+
export function acquireLock(docId, {userId, name, session, force = false, suggester = false}, now = Date.now()) {
|
|
584
|
+
const held = lockOf(docId, now);
|
|
585
|
+
if (held && held.session !== session && !force) return {ok: false, lock: held};
|
|
586
|
+
const lock = {userId, name: name || 'Someone', session, suggester: Boolean(suggester), since: held && held.session === session ? held.since : new Date(now).toISOString(), expires: now + LOCK_TTL_MS};
|
|
587
|
+
locks.set(docId, lock);
|
|
588
|
+
return {ok: true, lock, tookOver: Boolean(held && held.session !== session)};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export function releaseLock(docId, session) {
|
|
592
|
+
const held = locks.get(docId);
|
|
593
|
+
if (held && held.session === session) locks.delete(docId);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** The lock as the API shows it - never the session of somebody else. */
|
|
597
|
+
export function publicLock(lock, session) {
|
|
598
|
+
if (!lock) return null;
|
|
599
|
+
return {userId: lock.userId, name: lock.name, since: lock.since, mine: lock.session === session, suggester: Boolean(lock.suggester)};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function clearLocks() { locks.clear(); }
|
|
603
|
+
|
|
604
|
+
// ------------------------------------------------------------ public links
|
|
605
|
+
|
|
606
|
+
let linkIndex = null;
|
|
607
|
+
|
|
608
|
+
export function newLinkToken() {
|
|
609
|
+
return crypto.randomBytes(18).toString('base64url');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The document behind a public link, or null. Trashed documents and a site
|
|
614
|
+
* with public links switched off answer null.
|
|
615
|
+
*
|
|
616
|
+
* @param {string} token
|
|
617
|
+
* @returns {Promise<object|null>}
|
|
618
|
+
*/
|
|
619
|
+
export async function docByToken(token) {
|
|
620
|
+
if (!runtime.config.publicLinks) return null;
|
|
621
|
+
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{16,64}$/.test(token)) return null;
|
|
622
|
+
if (!linkIndex) {
|
|
623
|
+
linkIndex = new Map();
|
|
624
|
+
for (const d of await listDocs()) if (d.publicLink?.token) linkIndex.set(d.publicLink.token, d.id);
|
|
625
|
+
}
|
|
626
|
+
const id = linkIndex.get(token);
|
|
627
|
+
if (!id) return null;
|
|
628
|
+
const doc = await readDoc(id);
|
|
629
|
+
if (!doc || doc.trashedAt || doc.publicLink?.token !== token) return null;
|
|
630
|
+
return doc;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// -------------------------------------------------------------------- rows
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* A document as a list row: no body, an excerpt instead.
|
|
637
|
+
*
|
|
638
|
+
* @param {object} doc
|
|
639
|
+
* @param {object} ctx {user, isAdmin, state, names:Map, session}
|
|
640
|
+
*/
|
|
641
|
+
export function toRow(doc, {user, isAdmin, state, names}) {
|
|
642
|
+
const access = accessOf(doc, user, isAdmin);
|
|
643
|
+
const lock = lockOf(doc.id);
|
|
644
|
+
const opened = state.recent.find(r => r.id === doc.id)?.at || null;
|
|
645
|
+
return {
|
|
646
|
+
id: doc.id,
|
|
647
|
+
title: doc.title || 'Untitled',
|
|
648
|
+
excerpt: preview(doc.content, 400),
|
|
649
|
+
wordCount: Number(doc.wordCount) || 0,
|
|
650
|
+
// 2.13: a PDF document shows its pages and size where an HTML one shows words.
|
|
651
|
+
format: kindOf(doc),
|
|
652
|
+
...(kindOf(doc) === 'pdf' ? {file: {pages: Number(doc.file?.pages) || 0, size: Number(doc.file?.size) || 0}} : {}),
|
|
653
|
+
folderId: doc.userId === user.id ? (doc.folderId || null) : null,
|
|
654
|
+
userId: doc.userId,
|
|
655
|
+
ownerName: names.get(doc.userId) || 'Unknown',
|
|
656
|
+
access,
|
|
657
|
+
starred: state.starred.includes(doc.id),
|
|
658
|
+
shared: (doc.shares || []).length > 0,
|
|
659
|
+
public: Boolean(doc.publicLink?.token),
|
|
660
|
+
// 2.11: no Share or Email on Home for these (see outwardRefusal).
|
|
661
|
+
outward: !outwardRefusal(doc, user),
|
|
662
|
+
locked: lock && lock.userId !== user.id ? {name: lock.name, since: lock.since} : null,
|
|
663
|
+
createdAt: doc.createdAt,
|
|
664
|
+
updatedAt: doc.updatedAt,
|
|
665
|
+
openedAt: opened,
|
|
666
|
+
trashedAt: doc.trashedAt || null
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Pick and order the rows for one view of Home.
|
|
672
|
+
*
|
|
673
|
+
* @param {Array} docs every document on disk
|
|
674
|
+
* @param {object} q {view, folder, q}
|
|
675
|
+
* @param {object} ctx as toRow
|
|
676
|
+
* @returns {Array} rows
|
|
677
|
+
*/
|
|
678
|
+
/** Is `d` in this place (Home's rail) for this user? selectRows and placeCounts share it. */
|
|
679
|
+
export function inView(d, view, {user, isAdmin, state}) {
|
|
680
|
+
if (!accessOf(d, user, isAdmin)) return false;
|
|
681
|
+
if (view === 'trash') return Boolean(d.trashedAt) && d.userId === user.id;
|
|
682
|
+
if (d.trashedAt) return false;
|
|
683
|
+
if (onlyThroughExtension(d, user)) {
|
|
684
|
+
if (view === 'starred') return state.starred.includes(d.id);
|
|
685
|
+
if (view === 'recent') return state.recent.some(r => r.id === d.id);
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
switch (view) {
|
|
689
|
+
case 'starred': return state.starred.includes(d.id);
|
|
690
|
+
case 'shared': return d.userId !== user.id;
|
|
691
|
+
case 'recent': return true;
|
|
692
|
+
case 'all': return true;
|
|
693
|
+
default: return d.userId === user.id; // mine
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* How many documents each of Home's places shows when opened (2.10) - the
|
|
699
|
+
* numbers on the rail. "My documents" is its top level, as the list shows it
|
|
700
|
+
* (documents in folders are under their folder).
|
|
701
|
+
*
|
|
702
|
+
* @returns {{starred: number, shared: number, mine: number, trash: number}}
|
|
703
|
+
*/
|
|
704
|
+
export function placeCounts(docs, ctx) {
|
|
705
|
+
const n = (view, more = () => true) => docs.filter(d => inView(d, view, ctx) && more(d)).length;
|
|
706
|
+
return {starred: n('starred'), shared: n('shared'), mine: n('mine', d => !d.folderId), trash: n('trash')};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export function selectRows(docs, {view = 'mine', folder, q} = {}, ctx) {
|
|
710
|
+
const {user} = ctx;
|
|
711
|
+
const term = typeof q === 'string' ? q.trim().toLowerCase() : '';
|
|
712
|
+
const time = (iso) => Date.parse(iso || '') || 0;
|
|
713
|
+
let list = docs.filter(d => inView(d, view, ctx));
|
|
714
|
+
if (view === 'mine' && folder !== undefined) {
|
|
715
|
+
const f = folder === '' || folder === 'null' ? null : folder;
|
|
716
|
+
list = list.filter(d => (d.folderId || null) === f);
|
|
717
|
+
}
|
|
718
|
+
if (term) {
|
|
719
|
+
list = list.filter(d => (d.title || '').toLowerCase().includes(term)
|
|
720
|
+
|| String(d.content || '').replace(/<[^>]+>/g, ' ').toLowerCase().includes(term));
|
|
721
|
+
}
|
|
722
|
+
const rows = list.map(d => toRow(d, ctx));
|
|
723
|
+
if (view === 'recent') {
|
|
724
|
+
rows.sort((a, b) => Math.max(time(b.openedAt), b.userId === user.id ? time(b.updatedAt) : 0)
|
|
725
|
+
- Math.max(time(a.openedAt), a.userId === user.id ? time(a.updatedAt) : 0));
|
|
726
|
+
return rows.slice(0, 60);
|
|
727
|
+
}
|
|
728
|
+
if (view === 'trash') return rows.sort((a, b) => time(b.trashedAt) - time(a.trashedAt));
|
|
729
|
+
return rows.sort((a, b) => time(b.updatedAt) - time(a.updatedAt));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Remove documents that have sat in Trash longer than the setting allows.
|
|
734
|
+
*
|
|
735
|
+
* @returns {Promise<number>} how many went
|
|
736
|
+
*/
|
|
737
|
+
export async function purgeTrash(now = Date.now()) {
|
|
738
|
+
const days = Math.max(1, Number(runtime.config.trashDays) || 30);
|
|
739
|
+
let n = 0;
|
|
740
|
+
for (const d of await listDocs()) {
|
|
741
|
+
if (d.trashedAt && now - Date.parse(d.trashedAt) > days * 86400000) {
|
|
742
|
+
await removeDoc(d.id);
|
|
743
|
+
await forgetDoc(d.id);
|
|
744
|
+
n++;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return n;
|
|
748
|
+
}
|