diff --git a/.gitignore b/.gitignore index 5c734ff..e277443 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,4 @@ pnpm-debug.log* # temporary files web-scraper/temp/ -web-scraper/files/ -web-scraper/files2/ \ No newline at end of file +web-scraper/files* \ No newline at end of file diff --git a/astro.config.mjs b/astro.config.mjs index e4f6417..8032a6b 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -7,23 +7,11 @@ import playformCompress from '@playform/compress'; export default defineConfig({ site: 'https://cuza.pages.dev', trailingSlash: 'never', - redirects: { - '/fizica': '/', - }, build: { format: 'file', inlineStylesheets: 'always', - assets: 'file', }, vite: { - build: { - chunkSizeWarningLimit: 2000, - rollupOptions: { - output: { - assetFileNames: `file/[name][extname]`, - }, - }, - }, plugins: [tailwindcss()], }, markdown: { @@ -33,7 +21,10 @@ export default defineConfig({ }, integrations: [ sitemap({ - filter: (page) => !page.includes('/install') && !page.includes('/upload'), + filter: (page) => + !page.includes('/install') && + !page.includes('/upload') && + !page.includes('/contribute'), }), react(), playformCompress(), diff --git a/cuza-worker/src/app.ts b/cuza-worker/src/app.ts index 5cf5b11..902cc80 100644 --- a/cuza-worker/src/app.ts +++ b/cuza-worker/src/app.ts @@ -108,6 +108,17 @@ function getSubtree( return current; } +/** + * Get a subtree as an object, returning {} if the path doesn't exist or is a leaf. + */ +function getSubtreeAsObject( + index: FileStructure, + segments: string[], +): FileStructure { + const subtree = getSubtree(index, segments); + return subtree !== null && typeof subtree !== 'string' ? subtree : {}; +} + /** * Set a value at a nested path, creating intermediate objects as needed. */ @@ -270,18 +281,34 @@ function pruneMissingIndexLeaves( return cleaned; } -const VALID_PAGES = new Set(['bac', 'teste', 'sim']); +const VALID_PAGES = new Set(['bac', 'sim']); const VALID_SIMULATIONS = new Set(['judetene', 'locale']); const VALID_TYPE2 = new Set(['var', 'bar']); -const BAC_TITLE_TO_CODE: Record = { - Model: 'SM', - Simulare: 'sim', - 'Sesiunea-I': 'S1', - 'Sesiunea-II': 'S2', - 'Sesiune-speciala': 'SS', -}; +const VALID_VARIANTS = new Set([ + '01', + '02', + '03', + '04', + '05', + '06', + '07', + '08', + '09', + '10', +]); +const VARIANT_REQUIRED_TITLES = new Set([ + 'Sesiunea-I', + 'Sesiunea-II', + 'Sesiunea-speciala', +]); +const BAC_TITLES = [ + 'Model', + 'Simulare', + 'Sesiunea-I', + 'Sesiunea-II', + 'Sesiunea-speciala', +] as const; const YEAR_RE = /^20[1-3]\d$/; -const TEST_NUMBER_RE = /^\d{1,2}$/; const MAX_FILE_BYTES = 20 * 1024 * 1024; // 20 MB /** @@ -319,20 +346,14 @@ function validateFormData(formData: FormData): string | null { // ── Page-specific validation ─────────────────────────────────────────────── if (page === 'bac') { const title = formData.get('title') as string | null; - if (!title) { - return 'Lipsă titlu'; - } - if (!(title in BAC_TITLE_TO_CODE)) { - return 'Titlu BAC invalid'; - } - } - if (page === 'teste') { - const testNumber = formData.get('testNumber') as string | null; - if (!testNumber) { - return 'Lipsă număr test'; + if (!title || !(BAC_TITLES as readonly string[]).includes(title)) { + return 'Lipsă tip sau tip invalid'; } - if (!TEST_NUMBER_RE.test(testNumber)) { - return 'Număr test invalid'; + if (VARIANT_REQUIRED_TITLES.has(title)) { + const variant = formData.get('variant') as string | null; + if (!variant || !VALID_VARIANTS.has(variant)) { + return 'Lipsă variantă sau variantă invalidă (01-10)'; + } } } if (page === 'sim') { @@ -358,32 +379,25 @@ interface UploadPathData { page: string; year: string; title: string | null; + variant: string | null; type2: string; reserve: boolean; - testNumber: string | null; simulation: string | null; county: string | null; local: string | null; } function generateUploadPath(data: UploadPathData): { r2Key: string } { - const { page, year, type2, testNumber, simulation, reserve } = data; + const { page, year, variant, simulation, reserve, type2 } = data; const title = sanitizePathSegment(data.title); const county = sanitizePathSegment(data.county); const local = sanitizePathSegment(data.local); if (page === 'bac') { - const baseCode = BAC_TITLE_TO_CODE[data.title ?? '']; - const code = reserve ? `${baseCode}R` : baseCode; - return { - r2Key: `fizica/pages/teoretic/${year}/${title}/E_d_fizica_${year}_${code}_${type2}.pdf`, - }; - } - if (page === 'teste') { - const testNumberNormalized = (testNumber ?? '').padStart(2, '0'); - const testType = type2 === 'bar' ? 'Bar' : 'Test'; + const folderName = reserve ? `${title}-rezerva` : title; + const varSuffix = variant ? `_${variant}` : '_00'; return { - r2Key: `fizica/pages/teoretic/teste-de-antrenament/${year}/E_d_fizica_${year}_${testType}_${testNumberNormalized}.pdf`, + r2Key: `fizica/pages/teoretic/bac/${year}/${folderName}/E_d_fizica_teoretic_vocational_${year}_${type2}${varSuffix}.pdf`, }; } const location = simulation === 'judetene' ? county : local; @@ -410,23 +424,14 @@ export function registerRoutes(app: Hono<{ Bindings: Bindings }>): void { const index = await getIndex(c.env.FILES); const segments = resolvePathSegments(subject, page); - const subtree = getSubtree(index, segments); - const content: FileStructure = - subtree !== null && typeof subtree !== 'string' - ? (subtree as FileStructure) - : {}; + const content = getSubtreeAsObject(index, segments); const years = extractYears(content); - // Extra content const extraSegments = resolvePathSegments( subject, subject.toLowerCase() === 'admitere' ? `${page}/extra` : 'extra', ); - const extraSubtree = getSubtree(index, extraSegments); - const extra: FileStructure = - extraSubtree !== null && typeof extraSubtree !== 'string' - ? (extraSubtree as FileStructure) - : {}; + const extra = getSubtreeAsObject(index, extraSegments); return c.json({ content, extra, years }); }); @@ -475,25 +480,25 @@ export function registerRoutes(app: Hono<{ Bindings: Bindings }>): void { const page = formData.get('page') as string; const year = formData.get('year') as string; const title = formData.get('title') as string | null; - const type2 = formData.get('type2') as string; + const variant = formData.get('variant') as string | null; const reserveRaw = formData.get('reserve') as string | null; const reserve = reserveRaw === 'on' || reserveRaw === 'true'; - const testNumber = formData.get('testNumber') as string | null; const simulation = formData.get('simulation') as string | null; const county = formData.get('county') as string | null; const local = formData.get('local') as string | null; + const type2 = formData.get('type2') as string; const file = formData.get('file') as unknown as File; const { r2Key } = generateUploadPath({ page, year, title, - type2, + variant, reserve, - testNumber, simulation, county, local, + type2, }); await c.env.FILES.put(r2Key, file, { diff --git a/cuza-worker/src/index.ts b/cuza-worker/src/index.ts index 7c09204..89b48ee 100644 --- a/cuza-worker/src/index.ts +++ b/cuza-worker/src/index.ts @@ -16,6 +16,15 @@ app.use('*', async (c, next) => { return next(); }); +app.use('*', async (c, next) => { + await next(); + c.res.headers.set('X-Robots-Tag', 'noindex'); +}); + +app.get('/robots.txt', (c) => { + return c.text('User-agent: *\nDisallow: /\n'); +}); + app.use( '*', cors({ diff --git a/functions/api/[[route]].ts b/functions/api/[[route]].ts deleted file mode 100644 index df610dd..0000000 --- a/functions/api/[[route]].ts +++ /dev/null @@ -1,37 +0,0 @@ -/// - -export const onRequest: PagesFunction<{ FILES: R2Bucket }> = async (ctx) => { - const url = new URL(ctx.request.url); - const path = url.pathname.replace(/^\/api/, ''); - - if (path === '/ping') { - return new Response('Pong!', { status: 200 }); - } - - const fileMatch = path.match(/^\/file\/(.+)$/); - if (fileMatch) { - const key = fileMatch[1]; - const object = await ctx.env.FILES.get(key); - if (!object) return new Response('Not Found', { status: 404 }); - - const headers = new Headers(); - object.writeHttpMetadata(headers); - headers.set('etag', object.httpEtag); - headers.set('Cache-Control', 'public, max-age=31536000, immutable'); - - // Ensure PDFs are served inline so Googlebot can index them - const contentType = headers.get('Content-Type') || ''; - if ( - contentType.includes('application/pdf') || - key.toLowerCase().endsWith('.pdf') - ) { - headers.set('Content-Type', 'application/pdf'); - headers.set('Content-Disposition', 'inline'); - headers.set('X-Robots-Tag', 'index, follow'); - } - - return new Response(object.body, { headers }); - } - - return new Response('Not Found', { status: 404 }); -}; diff --git a/public/robots.txt b/public/robots.txt index 63bd157..49ac916 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,7 +1,7 @@ User-agent: * Allow: / -Allow: /api/file/ Disallow: /install Disallow: /upload +Disallow: /contribute Sitemap: https://cuza.pages.dev/sitemap-index.xml \ No newline at end of file diff --git a/public/sw.js b/public/sw.js index bdb648c..f6b4eed 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -var CACHE = 'cuza-pages-v3'; +var CACHE = 'cuza-pages-v4'; self.addEventListener('install', function (_event) { self.skipWaiting(); diff --git a/src/api.ts b/src/api.ts index 98c4d4f..63b292d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -7,8 +7,11 @@ export class ApiService { constructor(baseUrl?: string) { this.baseUrl = - (baseUrl ?? import.meta.env.PUBLIC_WORKER_URL.replace(/\/$/, '')) || - DEFAULT_WORKER_URL; + baseUrl ?? + (import.meta.env.PUBLIC_WORKER_URL ?? DEFAULT_WORKER_URL).replace( + /\/$/, + '', + ); } private async fetchJson(url: string): Promise { diff --git a/src/components/Extra.astro b/src/components/Extra.astro index 963ccc6..7fd7ce7 100644 --- a/src/components/Extra.astro +++ b/src/components/Extra.astro @@ -9,33 +9,50 @@ interface Props { page: string; extraData: FileStructure; years?: number[]; + contentFolders?: { key: string; label: string }[]; } -const { subject, page, extraData, years } = Astro.props as Props; +const { + subject, + page, + extraData, + years, + contentFolders = [], +} = Astro.props as Props; const isAdmitere = subject === 'admitere'; ---- -

Extra

-{ - (subject === 'admitere' && page === 'fizica') || subject === 'fizica' ? ( - - Culegeri fizică - - ) : null -} -{ - isAdmitere ? ( - - ) : ( - - ) -} - - +const shouldFilter2007 = + (subject === 'fizica' && page === 'teoretic') || + (subject === 'mate' && page === 'mate-info'); +const yearsfix = shouldFilter2007 ? years?.filter((y) => y !== 2007) : years; +--- - +
+
+ { + Object.keys(extraData).length > 0 && ( + <> +

Extra

+ {(subject === 'admitere' && page === 'fizica') || + subject === 'fizica' ? ( + + Culegeri fizică + + ) : null} + {isAdmitere ? ( + + ) : ( + + )} + + ) + } +
+ + +
diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 77ba366..be9f753 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -6,7 +6,7 @@ class="flex flex-col sm:flex-row justify-between items-center pt-3 gap-2" >

- Copyright © {new Date().getFullYear()} + Copyright © {new Date().getFullYear()}   /^20\d{2}$/.test(key); -const formatKey = (key: string): string => key.replace(/_/g, ' '); -const getFolderPriority = (key: string): number => { - const normalized = key.toLowerCase().replace(/[-_]+/g, ' '); - - const priorityRules: Array<[RegExp, number]> = [ - [/\badmitere\b/, 0], - [/\bpreadmitere\b/, 1], - [/\bsimululare\b/, 2], - [/\bsesiunea\s*(ii|2)\b/, 3], - [/\bsesiunea\s*(i|1)\b/, 4], - [/(special|olimp)/, 5], - [/\bmodel\b/, 6], - ]; - - for (const [pattern, priority] of priorityRules) { - if (pattern.test(normalized)) return priority; - } - - return 100; -}; +const formatKey = (key: string): string => key.replace(/_/g, '-'); const { subject, @@ -41,52 +23,71 @@ const { isExtra: isExtraProp, } = Astro.props as Props; -const workerUrl = - import.meta.env.PUBLIC_WORKER_URL.replace(/\/$/, '') || - 'https://api.my-lab.ro'; +const baseUrl = ( + import.meta.env.PUBLIC_WORKER_URL ?? 'https://api.my-lab.ro' +).replace(/\/$/, ''); // Use explicit prop in recursive calls, derive from page name at root. const isExtra = isExtraProp ?? (page === 'extra' || page.endsWith('/extra')); const cx = isExtra ? 'extra' : 'content'; const paddingLeft = `${depth}rem`; -const sortedEntries = Object.entries(content).sort( - ([keyA, valA], [keyB, valB]) => { - const aIsYear = isYearFolder(keyA); - const bIsYear = isYearFolder(keyB); - const aIsFile = typeof valA === 'string'; - const bIsFile = typeof valB === 'string'; +const entries = Object.entries(content); +const sortedEntries = entries + .map(([key, value]) => ({ + key, + value, + isYear: isYearFolder(key), + isFile: typeof value === 'string', + fileMatch: + typeof value === 'string' ? key.match(/_(var|bar)_(\d{2})/i) : null, + priority: typeof value === 'string' ? 100 : getFolderPriority(key), + })) + .sort((a, b) => { + if (a.isYear && b.isYear) return Number(b.key) - Number(a.key); + if (a.isYear !== b.isYear) return a.isYear ? -1 : 1; - if (aIsYear && bIsYear) { - return Number(keyB) - Number(keyA); - } + if (a.isFile && b.isFile) { + const aLow = /laicuza|hulubei/i.test(a.key); + const bLow = /laicuza|hulubei/i.test(b.key); + if (aLow !== bLow) return aLow ? 1 : -1; - if (aIsYear !== bIsYear) { - return aIsYear ? -1 : 1; - } + const aBase = a.key.replace(/_rasp\.pdf$/, '.pdf'); + const bBase = b.key.replace(/_rasp\.pdf$/, '.pdf'); + if (aBase === b.key) return 1; + if (bBase === a.key) return -1; + + const aParts = a.key.match(/^(.+?)_(var|bar|rasp)(.*)$/i); + const bParts = b.key.match(/^(.+?)_(var|bar|rasp)(.*)$/i); + if ( + aParts && + bParts && + aParts[1].toLowerCase() === bParts[1].toLowerCase() + ) { + const order: Record = { var: 0, bar: 1, rasp: 2 }; + const aType = aParts[2].toLowerCase(); + const bType = bParts[2].toLowerCase(); + const diff = (order[aType] ?? 0) - (order[bType] ?? 0); + if (diff !== 0) return diff; + } - if (aIsFile && bIsFile) { - const matchA = keyA.match(/_(var|bar)_(\d{2})/i); - const matchB = keyB.match(/_(var|bar)_(\d{2})/i); - if (matchA && matchB) { - const numDiff = parseInt(matchA[2]) - parseInt(matchB[2]); + if (a.fileMatch && b.fileMatch) { + const numDiff = parseInt(a.fileMatch[2]) - parseInt(b.fileMatch[2]); if (numDiff !== 0) return numDiff; - const typeA = matchA[1].toLowerCase() === 'var' ? 0 : 1; - const typeB = matchB[1].toLowerCase() === 'var' ? 0 : 1; + const typeA = a.fileMatch[1].toLowerCase() === 'var' ? 0 : 1; + const typeB = b.fileMatch[1].toLowerCase() === 'var' ? 0 : 1; return typeA - typeB; } } - if (aIsFile !== bIsFile) { - return aIsFile ? 1 : -1; - } + if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; - const priorityDiff = getFolderPriority(keyA) - getFolderPriority(keyB); + const priorityDiff = a.priority - b.priority; if (priorityDiff !== 0) return priorityDiff; - return keyB.localeCompare(keyA); - }, -); + return a.key.localeCompare(b.key); + }) + .map(({ key, value }) => [key, value] as [string, FileStructure[string]]); ---

@@ -95,7 +96,10 @@ const sortedEntries = Object.entries(content).sort( sortedEntries.length > 0 ? (